From 9b0def6ea1b20460874481cf8ccb575b6508636d Mon Sep 17 00:00:00 2001 From: "Houston (Bot)" <108291165+astrobot-houston@users.noreply.github.com> Date: Thu, 5 Mar 2026 01:19:09 -0800 Subject: [PATCH 1/6] [ci] release (beta) (#15758) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/pre.json | 5 ++ examples/basics/package.json | 2 +- examples/blog/package.json | 4 +- examples/component/package.json | 2 +- examples/container-with-vitest/package.json | 2 +- examples/framework-alpine/package.json | 2 +- examples/framework-multiple/package.json | 2 +- examples/framework-preact/package.json | 2 +- examples/framework-react/package.json | 2 +- examples/framework-solid/package.json | 2 +- examples/framework-svelte/package.json | 2 +- examples/framework-vue/package.json | 2 +- examples/hackernews/package.json | 4 +- examples/integration/package.json | 2 +- examples/minimal/package.json | 2 +- examples/portfolio/package.json | 2 +- examples/ssr/package.json | 4 +- examples/starlog/package.json | 2 +- examples/toolbar-app/package.json | 2 +- examples/with-markdoc/package.json | 4 +- examples/with-mdx/package.json | 4 +- examples/with-nanostores/package.json | 2 +- examples/with-tailwindcss/package.json | 4 +- examples/with-vitest/package.json | 2 +- packages/astro/CHANGELOG.md | 16 +++++ packages/astro/package.json | 2 +- packages/integrations/cloudflare/CHANGELOG.md | 8 +++ packages/integrations/cloudflare/package.json | 2 +- packages/integrations/markdoc/CHANGELOG.md | 8 +++ packages/integrations/markdoc/package.json | 2 +- packages/integrations/mdx/CHANGELOG.md | 7 +++ packages/integrations/mdx/package.json | 2 +- packages/integrations/netlify/CHANGELOG.md | 8 +++ packages/integrations/netlify/package.json | 2 +- packages/integrations/node/CHANGELOG.md | 7 +++ packages/integrations/node/package.json | 2 +- packages/integrations/vercel/CHANGELOG.md | 7 +++ packages/integrations/vercel/package.json | 2 +- packages/internal-helpers/CHANGELOG.md | 6 ++ packages/internal-helpers/package.json | 2 +- packages/markdown/remark/CHANGELOG.md | 7 +++ packages/markdown/remark/package.json | 2 +- pnpm-lock.yaml | 58 +++++++++---------- 43 files changed, 146 insertions(+), 67 deletions(-) diff --git a/.changeset/pre.json b/.changeset/pre.json index a87f5bc6248b..e0b7175ebe0e 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -96,6 +96,7 @@ "eager-owls-stare", "early-badgers-pull", "early-times-drop", + "eight-lines-dig", "emit-client-asset-api", "encoding-static-builds", "every-carpets-grin", @@ -121,6 +122,7 @@ "fix-netlify-ssr-routing", "fix-preact-cloudflare-hooks", "fix-rewrite-non-ascii-paths", + "fix-safari-persist-canvas-context-loss", "fix-serve-files-outside-srcdir", "fix-server-island-dev-build-output", "fix-session-regenerate-dirty", @@ -153,6 +155,7 @@ "grumpy-tables-serve", "happy-falcons-show", "harden-attribute-escaping", + "harden-dev-server-sec-fetch", "harden-merge-responses-cookies", "harden-xff-allowed-domains", "heavy-beers-unite", @@ -214,6 +217,7 @@ "public-lemons-mate", "puny-dragons-fail", "puny-poems-create", + "purple-steaks-begin", "quick-dingos-itch", "quiet-cars-burn", "quiet-owls-jump", @@ -290,6 +294,7 @@ "warm-comics-pump", "warm-donuts-learn", "warm-dots-glow", + "wet-animals-pump", "wet-lines-wear", "wet-suits-help", "whole-geckos-think", diff --git a/examples/basics/package.json b/examples/basics/package.json index 6577738f03b1..5feee4a718a7 100644 --- a/examples/basics/package.json +++ b/examples/basics/package.json @@ -13,6 +13,6 @@ "astro": "astro" }, "dependencies": { - "astro": "^6.0.0-beta.18" + "astro": "^6.0.0-beta.19" } } diff --git a/examples/blog/package.json b/examples/blog/package.json index f2fe6aafebea..1946230e2a09 100644 --- a/examples/blog/package.json +++ b/examples/blog/package.json @@ -13,10 +13,10 @@ "astro": "astro" }, "dependencies": { - "@astrojs/mdx": "^5.0.0-beta.10", + "@astrojs/mdx": "^5.0.0-beta.11", "@astrojs/rss": "^4.0.15-beta.4", "@astrojs/sitemap": "^3.6.1-beta.3", - "astro": "^6.0.0-beta.18", + "astro": "^6.0.0-beta.19", "sharp": "^0.34.3" } } diff --git a/examples/component/package.json b/examples/component/package.json index 66a9ff5f2551..5b63ec25ed15 100644 --- a/examples/component/package.json +++ b/examples/component/package.json @@ -18,7 +18,7 @@ ], "scripts": {}, "devDependencies": { - "astro": "^6.0.0-beta.18" + "astro": "^6.0.0-beta.19" }, "peerDependencies": { "astro": "^5.0.0 || ^6.0.0" diff --git a/examples/container-with-vitest/package.json b/examples/container-with-vitest/package.json index 1c8b0fcdf6c0..1605adc93284 100644 --- a/examples/container-with-vitest/package.json +++ b/examples/container-with-vitest/package.json @@ -15,7 +15,7 @@ }, "dependencies": { "@astrojs/react": "^5.0.0-beta.3", - "astro": "^6.0.0-beta.18", + "astro": "^6.0.0-beta.19", "react": "^18.3.1", "react-dom": "^18.3.1", "vitest": "^3.2.4" diff --git a/examples/framework-alpine/package.json b/examples/framework-alpine/package.json index d786b6c21c82..abba6ca2ee1f 100644 --- a/examples/framework-alpine/package.json +++ b/examples/framework-alpine/package.json @@ -16,6 +16,6 @@ "@astrojs/alpinejs": "^0.5.0-beta.1", "@types/alpinejs": "^3.13.11", "alpinejs": "^3.15.8", - "astro": "^6.0.0-beta.18" + "astro": "^6.0.0-beta.19" } } diff --git a/examples/framework-multiple/package.json b/examples/framework-multiple/package.json index aa14e791aa22..af5142e5a320 100644 --- a/examples/framework-multiple/package.json +++ b/examples/framework-multiple/package.json @@ -20,7 +20,7 @@ "@astrojs/vue": "^6.0.0-beta.1", "@types/react": "^18.3.28", "@types/react-dom": "^18.3.7", - "astro": "^6.0.0-beta.18", + "astro": "^6.0.0-beta.19", "preact": "^10.28.4", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/examples/framework-preact/package.json b/examples/framework-preact/package.json index f89d75a41969..b0484a4522c6 100644 --- a/examples/framework-preact/package.json +++ b/examples/framework-preact/package.json @@ -15,7 +15,7 @@ "dependencies": { "@astrojs/preact": "^5.0.0-beta.4", "@preact/signals": "^2.8.1", - "astro": "^6.0.0-beta.18", + "astro": "^6.0.0-beta.19", "preact": "^10.28.4" } } diff --git a/examples/framework-react/package.json b/examples/framework-react/package.json index be59dfc0b627..4dc73eff00c5 100644 --- a/examples/framework-react/package.json +++ b/examples/framework-react/package.json @@ -16,7 +16,7 @@ "@astrojs/react": "^5.0.0-beta.3", "@types/react": "^18.3.28", "@types/react-dom": "^18.3.7", - "astro": "^6.0.0-beta.18", + "astro": "^6.0.0-beta.19", "react": "^18.3.1", "react-dom": "^18.3.1" } diff --git a/examples/framework-solid/package.json b/examples/framework-solid/package.json index 0adc50679cf7..8f071aaaaed5 100644 --- a/examples/framework-solid/package.json +++ b/examples/framework-solid/package.json @@ -14,7 +14,7 @@ }, "dependencies": { "@astrojs/solid-js": "^6.0.0-beta.2", - "astro": "^6.0.0-beta.18", + "astro": "^6.0.0-beta.19", "solid-js": "^1.9.11" } } diff --git a/examples/framework-svelte/package.json b/examples/framework-svelte/package.json index 34e44e9b1345..eb3a5d63577a 100644 --- a/examples/framework-svelte/package.json +++ b/examples/framework-svelte/package.json @@ -14,7 +14,7 @@ }, "dependencies": { "@astrojs/svelte": "^8.0.0-beta.3", - "astro": "^6.0.0-beta.18", + "astro": "^6.0.0-beta.19", "svelte": "^5.53.5" } } diff --git a/examples/framework-vue/package.json b/examples/framework-vue/package.json index 4c0de677fc52..a3ff1c61623f 100644 --- a/examples/framework-vue/package.json +++ b/examples/framework-vue/package.json @@ -14,7 +14,7 @@ }, "dependencies": { "@astrojs/vue": "^6.0.0-beta.1", - "astro": "^6.0.0-beta.18", + "astro": "^6.0.0-beta.19", "vue": "^3.5.29" } } diff --git a/examples/hackernews/package.json b/examples/hackernews/package.json index 4bfb774d04ea..3a4a1d356830 100644 --- a/examples/hackernews/package.json +++ b/examples/hackernews/package.json @@ -13,7 +13,7 @@ "astro": "astro" }, "dependencies": { - "@astrojs/node": "^10.0.0-beta.7", - "astro": "^6.0.0-beta.18" + "@astrojs/node": "^10.0.0-beta.8", + "astro": "^6.0.0-beta.19" } } diff --git a/examples/integration/package.json b/examples/integration/package.json index 7327cf3a637b..56d98f2e68b9 100644 --- a/examples/integration/package.json +++ b/examples/integration/package.json @@ -18,7 +18,7 @@ ], "scripts": {}, "devDependencies": { - "astro": "^6.0.0-beta.18" + "astro": "^6.0.0-beta.19" }, "peerDependencies": { "astro": "^4.0.0" diff --git a/examples/minimal/package.json b/examples/minimal/package.json index 1c5d7d525809..c9157728bf8f 100644 --- a/examples/minimal/package.json +++ b/examples/minimal/package.json @@ -13,6 +13,6 @@ "astro": "astro" }, "dependencies": { - "astro": "^6.0.0-beta.18" + "astro": "^6.0.0-beta.19" } } diff --git a/examples/portfolio/package.json b/examples/portfolio/package.json index 828e904a8c1a..a772948f5171 100644 --- a/examples/portfolio/package.json +++ b/examples/portfolio/package.json @@ -13,6 +13,6 @@ "astro": "astro" }, "dependencies": { - "astro": "^6.0.0-beta.18" + "astro": "^6.0.0-beta.19" } } diff --git a/examples/ssr/package.json b/examples/ssr/package.json index 8e6a32b827ae..850056bc2647 100644 --- a/examples/ssr/package.json +++ b/examples/ssr/package.json @@ -14,9 +14,9 @@ "server": "node dist/server/entry.mjs" }, "dependencies": { - "@astrojs/node": "^10.0.0-beta.7", + "@astrojs/node": "^10.0.0-beta.8", "@astrojs/svelte": "^8.0.0-beta.3", - "astro": "^6.0.0-beta.18", + "astro": "^6.0.0-beta.19", "svelte": "^5.53.5" } } diff --git a/examples/starlog/package.json b/examples/starlog/package.json index de305ae40dfd..5a976e66f956 100644 --- a/examples/starlog/package.json +++ b/examples/starlog/package.json @@ -9,7 +9,7 @@ "astro": "astro" }, "dependencies": { - "astro": "^6.0.0-beta.18", + "astro": "^6.0.0-beta.19", "sass": "^1.97.3", "sharp": "^0.34.3" }, diff --git a/examples/toolbar-app/package.json b/examples/toolbar-app/package.json index 1856e6cdad9d..af20f18071ef 100644 --- a/examples/toolbar-app/package.json +++ b/examples/toolbar-app/package.json @@ -16,7 +16,7 @@ }, "devDependencies": { "@types/node": "^18.17.8", - "astro": "^6.0.0-beta.18" + "astro": "^6.0.0-beta.19" }, "engines": { "node": ">=22.12.0" diff --git a/examples/with-markdoc/package.json b/examples/with-markdoc/package.json index e4c3b09effce..c015de6d105b 100644 --- a/examples/with-markdoc/package.json +++ b/examples/with-markdoc/package.json @@ -13,7 +13,7 @@ "astro": "astro" }, "dependencies": { - "@astrojs/markdoc": "^1.0.0-beta.13", - "astro": "^6.0.0-beta.18" + "@astrojs/markdoc": "^1.0.0-beta.14", + "astro": "^6.0.0-beta.19" } } diff --git a/examples/with-mdx/package.json b/examples/with-mdx/package.json index 73f889680e39..ab6207dbbd22 100644 --- a/examples/with-mdx/package.json +++ b/examples/with-mdx/package.json @@ -13,9 +13,9 @@ "astro": "astro" }, "dependencies": { - "@astrojs/mdx": "^5.0.0-beta.10", + "@astrojs/mdx": "^5.0.0-beta.11", "@astrojs/preact": "^5.0.0-beta.4", - "astro": "^6.0.0-beta.18", + "astro": "^6.0.0-beta.19", "preact": "^10.28.4" } } diff --git a/examples/with-nanostores/package.json b/examples/with-nanostores/package.json index 4a5536d3931f..ae320eb5588f 100644 --- a/examples/with-nanostores/package.json +++ b/examples/with-nanostores/package.json @@ -15,7 +15,7 @@ "dependencies": { "@astrojs/preact": "^5.0.0-beta.4", "@nanostores/preact": "^1.0.0", - "astro": "^6.0.0-beta.18", + "astro": "^6.0.0-beta.19", "nanostores": "^1.1.1", "preact": "^10.28.4" } diff --git a/examples/with-tailwindcss/package.json b/examples/with-tailwindcss/package.json index f09645e978c7..901e37551306 100644 --- a/examples/with-tailwindcss/package.json +++ b/examples/with-tailwindcss/package.json @@ -13,10 +13,10 @@ "astro": "astro" }, "dependencies": { - "@astrojs/mdx": "^5.0.0-beta.10", + "@astrojs/mdx": "^5.0.0-beta.11", "@tailwindcss/vite": "^4.2.1", "@types/canvas-confetti": "^1.9.0", - "astro": "^6.0.0-beta.18", + "astro": "^6.0.0-beta.19", "canvas-confetti": "^1.9.4", "tailwindcss": "^4.2.1" } diff --git a/examples/with-vitest/package.json b/examples/with-vitest/package.json index ff8f1e55cc02..111ad0f98b3f 100644 --- a/examples/with-vitest/package.json +++ b/examples/with-vitest/package.json @@ -14,7 +14,7 @@ "test": "vitest" }, "dependencies": { - "astro": "^6.0.0-beta.18", + "astro": "^6.0.0-beta.19", "vitest": "^3.2.4" } } diff --git a/packages/astro/CHANGELOG.md b/packages/astro/CHANGELOG.md index 49786be75e72..98d1bebdbdec 100644 --- a/packages/astro/CHANGELOG.md +++ b/packages/astro/CHANGELOG.md @@ -1,5 +1,21 @@ # astro +## 6.0.0-beta.19 + +### Patch Changes + +- [#15760](https://github.com/withastro/astro/pull/15760) [`f49a27f`](https://github.com/withastro/astro/commit/f49a27fd2ac2559c06671979487f642360791a92) Thanks [@ematipico](https://github.com/ematipico)! - Fixed an issue where queued rendering wasn't correctly re-using the saved nodes. + +- [#15728](https://github.com/withastro/astro/pull/15728) [`12ca621`](https://github.com/withastro/astro/commit/12ca6213a68280293485d091e14899e7f2a4fee8) Thanks [@SvetimFM](https://github.com/SvetimFM)! - Improves internal state retention for persisted elements during view transitions, especially avoiding WebGL context loss in Safari and resets of CSS transitions and iframes in modern Chromium and Firefox browsers + +- [#15756](https://github.com/withastro/astro/pull/15756) [`b6c64d1`](https://github.com/withastro/astro/commit/b6c64d1760ded517db37e1dd86a909959f7f619d) Thanks [@matthewp](https://github.com/matthewp)! - Hardens the dev server by validating Sec-Fetch metadata headers to restrict cross-origin subresource requests + +- [#15414](https://github.com/withastro/astro/pull/15414) [`faedcc4`](https://github.com/withastro/astro/commit/faedcc40bccc43e27a53eee495b34448532866d6) Thanks [@sapphi-red](https://github.com/sapphi-red)! - Fixes a bug where some requests to the dev server didn't start with the leading `/`. + +- Updated dependencies [[`745e632`](https://github.com/withastro/astro/commit/745e632fc590e41a5701509e9cc4ed971bdddf74)]: + - @astrojs/internal-helpers@0.8.0-beta.2 + - @astrojs/markdown-remark@7.0.0-beta.10 + ## 6.0.0-beta.18 ### Major Changes diff --git a/packages/astro/package.json b/packages/astro/package.json index fd482524d5f1..e1f09d859c76 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -1,6 +1,6 @@ { "name": "astro", - "version": "6.0.0-beta.18", + "version": "6.0.0-beta.19", "description": "Astro is a modern site builder with web best practices, performance, and DX front-of-mind.", "type": "module", "author": "withastro", diff --git a/packages/integrations/cloudflare/CHANGELOG.md b/packages/integrations/cloudflare/CHANGELOG.md index 410c97ba87da..1458e7363684 100644 --- a/packages/integrations/cloudflare/CHANGELOG.md +++ b/packages/integrations/cloudflare/CHANGELOG.md @@ -1,5 +1,13 @@ # @astrojs/cloudflare +## 13.0.0-beta.13 + +### Patch Changes + +- Updated dependencies [[`745e632`](https://github.com/withastro/astro/commit/745e632fc590e41a5701509e9cc4ed971bdddf74)]: + - @astrojs/internal-helpers@0.8.0-beta.2 + - @astrojs/underscore-redirects@1.0.0 + ## 13.0.0-beta.12 ### Patch Changes diff --git a/packages/integrations/cloudflare/package.json b/packages/integrations/cloudflare/package.json index 137169e8eea7..bab0d2776f63 100644 --- a/packages/integrations/cloudflare/package.json +++ b/packages/integrations/cloudflare/package.json @@ -1,7 +1,7 @@ { "name": "@astrojs/cloudflare", "description": "Deploy your site to Cloudflare Workers", - "version": "13.0.0-beta.12", + "version": "13.0.0-beta.13", "type": "module", "types": "./dist/index.d.ts", "author": "withastro", diff --git a/packages/integrations/markdoc/CHANGELOG.md b/packages/integrations/markdoc/CHANGELOG.md index a4549323299a..c71d51ee9596 100644 --- a/packages/integrations/markdoc/CHANGELOG.md +++ b/packages/integrations/markdoc/CHANGELOG.md @@ -1,5 +1,13 @@ # @astrojs/markdoc +## 1.0.0-beta.14 + +### Patch Changes + +- Updated dependencies [[`745e632`](https://github.com/withastro/astro/commit/745e632fc590e41a5701509e9cc4ed971bdddf74)]: + - @astrojs/internal-helpers@0.8.0-beta.2 + - @astrojs/markdown-remark@7.0.0-beta.10 + ## 1.0.0-beta.13 ### Patch Changes diff --git a/packages/integrations/markdoc/package.json b/packages/integrations/markdoc/package.json index ea303ca9f8c7..c97eb58638f2 100644 --- a/packages/integrations/markdoc/package.json +++ b/packages/integrations/markdoc/package.json @@ -1,7 +1,7 @@ { "name": "@astrojs/markdoc", "description": "Add support for Markdoc in your Astro site", - "version": "1.0.0-beta.13", + "version": "1.0.0-beta.14", "type": "module", "types": "./dist/index.d.ts", "author": "withastro", diff --git a/packages/integrations/mdx/CHANGELOG.md b/packages/integrations/mdx/CHANGELOG.md index e8f2e239f2df..c5a6842d8a43 100644 --- a/packages/integrations/mdx/CHANGELOG.md +++ b/packages/integrations/mdx/CHANGELOG.md @@ -1,5 +1,12 @@ # @astrojs/mdx +## 5.0.0-beta.11 + +### Patch Changes + +- Updated dependencies []: + - @astrojs/markdown-remark@7.0.0-beta.10 + ## 5.0.0-beta.10 ### Patch Changes diff --git a/packages/integrations/mdx/package.json b/packages/integrations/mdx/package.json index 0797dd1e7526..2e7eb81104f7 100644 --- a/packages/integrations/mdx/package.json +++ b/packages/integrations/mdx/package.json @@ -1,7 +1,7 @@ { "name": "@astrojs/mdx", "description": "Add support for MDX pages in your Astro site", - "version": "5.0.0-beta.10", + "version": "5.0.0-beta.11", "type": "module", "types": "./dist/index.d.ts", "author": "withastro", diff --git a/packages/integrations/netlify/CHANGELOG.md b/packages/integrations/netlify/CHANGELOG.md index 92ad4e446304..f0d82f9f8799 100644 --- a/packages/integrations/netlify/CHANGELOG.md +++ b/packages/integrations/netlify/CHANGELOG.md @@ -1,5 +1,13 @@ # @astrojs/netlify +## 7.0.0-beta.13 + +### Patch Changes + +- Updated dependencies [[`745e632`](https://github.com/withastro/astro/commit/745e632fc590e41a5701509e9cc4ed971bdddf74)]: + - @astrojs/internal-helpers@0.8.0-beta.2 + - @astrojs/underscore-redirects@1.0.0 + ## 7.0.0-beta.12 ### Minor Changes diff --git a/packages/integrations/netlify/package.json b/packages/integrations/netlify/package.json index 9d23de93a6f5..4ac12ee40f74 100644 --- a/packages/integrations/netlify/package.json +++ b/packages/integrations/netlify/package.json @@ -1,7 +1,7 @@ { "name": "@astrojs/netlify", "description": "Deploy your site to Netlify", - "version": "7.0.0-beta.12", + "version": "7.0.0-beta.13", "type": "module", "types": "./dist/index.d.ts", "author": "withastro", diff --git a/packages/integrations/node/CHANGELOG.md b/packages/integrations/node/CHANGELOG.md index ba8dac78dec3..091745d317e1 100644 --- a/packages/integrations/node/CHANGELOG.md +++ b/packages/integrations/node/CHANGELOG.md @@ -1,5 +1,12 @@ # @astrojs/node +## 10.0.0-beta.8 + +### Patch Changes + +- Updated dependencies [[`745e632`](https://github.com/withastro/astro/commit/745e632fc590e41a5701509e9cc4ed971bdddf74)]: + - @astrojs/internal-helpers@0.8.0-beta.2 + ## 10.0.0-beta.7 ### Major Changes diff --git a/packages/integrations/node/package.json b/packages/integrations/node/package.json index 7cf5dfcfd4c5..f455a3a166f7 100644 --- a/packages/integrations/node/package.json +++ b/packages/integrations/node/package.json @@ -1,7 +1,7 @@ { "name": "@astrojs/node", "description": "Deploy your site to a Node.js server", - "version": "10.0.0-beta.7", + "version": "10.0.0-beta.8", "type": "module", "types": "./dist/index.d.ts", "author": "withastro", diff --git a/packages/integrations/vercel/CHANGELOG.md b/packages/integrations/vercel/CHANGELOG.md index e7b6593ce5d4..0886df9ce586 100644 --- a/packages/integrations/vercel/CHANGELOG.md +++ b/packages/integrations/vercel/CHANGELOG.md @@ -1,5 +1,12 @@ # @astrojs/vercel +## 10.0.0-beta.7 + +### Patch Changes + +- Updated dependencies [[`745e632`](https://github.com/withastro/astro/commit/745e632fc590e41a5701509e9cc4ed971bdddf74)]: + - @astrojs/internal-helpers@0.8.0-beta.2 + ## 10.0.0-beta.6 ### Minor Changes diff --git a/packages/integrations/vercel/package.json b/packages/integrations/vercel/package.json index 5147cb025c8e..5e5250938b27 100644 --- a/packages/integrations/vercel/package.json +++ b/packages/integrations/vercel/package.json @@ -1,7 +1,7 @@ { "name": "@astrojs/vercel", "description": "Deploy your site to Vercel", - "version": "10.0.0-beta.6", + "version": "10.0.0-beta.7", "type": "module", "author": "withastro", "license": "MIT", diff --git a/packages/internal-helpers/CHANGELOG.md b/packages/internal-helpers/CHANGELOG.md index 2e4fdeb1b441..d8b8f1a665dd 100644 --- a/packages/internal-helpers/CHANGELOG.md +++ b/packages/internal-helpers/CHANGELOG.md @@ -1,5 +1,11 @@ # @astrojs/internal-helpers +## 0.8.0-beta.2 + +### Minor Changes + +- [#15771](https://github.com/withastro/astro/pull/15771) [`745e632`](https://github.com/withastro/astro/commit/745e632fc590e41a5701509e9cc4ed971bdddf74) Thanks [@rururux](https://github.com/rururux)! - Adds the new utilities `MANY_LEADING_SLASHES` and `collapseDuplicateLeadingSlashes`. + ## 0.8.0-beta.1 ### Minor Changes diff --git a/packages/internal-helpers/package.json b/packages/internal-helpers/package.json index fb50f7f47dc6..42d06af36a2e 100644 --- a/packages/internal-helpers/package.json +++ b/packages/internal-helpers/package.json @@ -1,7 +1,7 @@ { "name": "@astrojs/internal-helpers", "description": "Internal helpers used by core Astro packages.", - "version": "0.8.0-beta.1", + "version": "0.8.0-beta.2", "type": "module", "author": "withastro", "license": "MIT", diff --git a/packages/markdown/remark/CHANGELOG.md b/packages/markdown/remark/CHANGELOG.md index e90ad46fb245..53e8ddfddfeb 100644 --- a/packages/markdown/remark/CHANGELOG.md +++ b/packages/markdown/remark/CHANGELOG.md @@ -1,5 +1,12 @@ # @astrojs/markdown-remark +## 7.0.0-beta.10 + +### Patch Changes + +- Updated dependencies [[`745e632`](https://github.com/withastro/astro/commit/745e632fc590e41a5701509e9cc4ed971bdddf74)]: + - @astrojs/internal-helpers@0.8.0-beta.2 + ## 7.0.0-beta.9 ### Major Changes diff --git a/packages/markdown/remark/package.json b/packages/markdown/remark/package.json index 7561b17f0eb3..20676622edfb 100644 --- a/packages/markdown/remark/package.json +++ b/packages/markdown/remark/package.json @@ -1,6 +1,6 @@ { "name": "@astrojs/markdown-remark", - "version": "7.0.0-beta.9", + "version": "7.0.0-beta.10", "type": "module", "author": "withastro", "license": "MIT", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d7a07a90f37c..d30f781a572c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -184,13 +184,13 @@ importers: examples/basics: dependencies: astro: - specifier: ^6.0.0-beta.18 + specifier: ^6.0.0-beta.19 version: link:../../packages/astro examples/blog: dependencies: '@astrojs/mdx': - specifier: ^5.0.0-beta.10 + specifier: ^5.0.0-beta.11 version: link:../../packages/integrations/mdx '@astrojs/rss': specifier: ^4.0.15-beta.4 @@ -199,7 +199,7 @@ importers: specifier: ^3.6.1-beta.3 version: link:../../packages/integrations/sitemap astro: - specifier: ^6.0.0-beta.18 + specifier: ^6.0.0-beta.19 version: link:../../packages/astro sharp: specifier: ^0.34.3 @@ -208,7 +208,7 @@ importers: examples/component: devDependencies: astro: - specifier: ^6.0.0-beta.18 + specifier: ^6.0.0-beta.19 version: link:../../packages/astro examples/container-with-vitest: @@ -217,7 +217,7 @@ importers: specifier: ^5.0.0-beta.3 version: link:../../packages/integrations/react astro: - specifier: ^6.0.0-beta.18 + specifier: ^6.0.0-beta.19 version: link:../../packages/astro react: specifier: ^18.3.1 @@ -248,7 +248,7 @@ importers: specifier: ^3.15.8 version: 3.15.8 astro: - specifier: ^6.0.0-beta.18 + specifier: ^6.0.0-beta.19 version: link:../../packages/astro examples/framework-multiple: @@ -275,7 +275,7 @@ importers: specifier: ^18.3.7 version: 18.3.7(@types/react@18.3.28) astro: - specifier: ^6.0.0-beta.18 + specifier: ^6.0.0-beta.19 version: link:../../packages/astro preact: specifier: ^10.28.4 @@ -305,7 +305,7 @@ importers: specifier: ^2.8.1 version: 2.8.1(preact@10.28.4) astro: - specifier: ^6.0.0-beta.18 + specifier: ^6.0.0-beta.19 version: link:../../packages/astro preact: specifier: ^10.28.4 @@ -323,7 +323,7 @@ importers: specifier: ^18.3.7 version: 18.3.7(@types/react@18.3.28) astro: - specifier: ^6.0.0-beta.18 + specifier: ^6.0.0-beta.19 version: link:../../packages/astro react: specifier: ^18.3.1 @@ -338,7 +338,7 @@ importers: specifier: ^6.0.0-beta.2 version: link:../../packages/integrations/solid astro: - specifier: ^6.0.0-beta.18 + specifier: ^6.0.0-beta.19 version: link:../../packages/astro solid-js: specifier: ^1.9.11 @@ -350,7 +350,7 @@ importers: specifier: ^8.0.0-beta.3 version: link:../../packages/integrations/svelte astro: - specifier: ^6.0.0-beta.18 + specifier: ^6.0.0-beta.19 version: link:../../packages/astro svelte: specifier: ^5.53.5 @@ -362,7 +362,7 @@ importers: specifier: ^6.0.0-beta.1 version: link:../../packages/integrations/vue astro: - specifier: ^6.0.0-beta.18 + specifier: ^6.0.0-beta.19 version: link:../../packages/astro vue: specifier: ^3.5.29 @@ -371,40 +371,40 @@ importers: examples/hackernews: dependencies: '@astrojs/node': - specifier: ^10.0.0-beta.7 + specifier: ^10.0.0-beta.8 version: link:../../packages/integrations/node astro: - specifier: ^6.0.0-beta.18 + specifier: ^6.0.0-beta.19 version: link:../../packages/astro examples/integration: devDependencies: astro: - specifier: ^6.0.0-beta.18 + specifier: ^6.0.0-beta.19 version: link:../../packages/astro examples/minimal: dependencies: astro: - specifier: ^6.0.0-beta.18 + specifier: ^6.0.0-beta.19 version: link:../../packages/astro examples/portfolio: dependencies: astro: - specifier: ^6.0.0-beta.18 + specifier: ^6.0.0-beta.19 version: link:../../packages/astro examples/ssr: dependencies: '@astrojs/node': - specifier: ^10.0.0-beta.7 + specifier: ^10.0.0-beta.8 version: link:../../packages/integrations/node '@astrojs/svelte': specifier: ^8.0.0-beta.3 version: link:../../packages/integrations/svelte astro: - specifier: ^6.0.0-beta.18 + specifier: ^6.0.0-beta.19 version: link:../../packages/astro svelte: specifier: ^5.53.5 @@ -413,7 +413,7 @@ importers: examples/starlog: dependencies: astro: - specifier: ^6.0.0-beta.18 + specifier: ^6.0.0-beta.19 version: link:../../packages/astro sass: specifier: ^1.97.3 @@ -428,28 +428,28 @@ importers: specifier: ^18.17.8 version: 18.19.130 astro: - specifier: ^6.0.0-beta.18 + specifier: ^6.0.0-beta.19 version: link:../../packages/astro examples/with-markdoc: dependencies: '@astrojs/markdoc': - specifier: ^1.0.0-beta.13 + specifier: ^1.0.0-beta.14 version: link:../../packages/integrations/markdoc astro: - specifier: ^6.0.0-beta.18 + specifier: ^6.0.0-beta.19 version: link:../../packages/astro examples/with-mdx: dependencies: '@astrojs/mdx': - specifier: ^5.0.0-beta.10 + specifier: ^5.0.0-beta.11 version: link:../../packages/integrations/mdx '@astrojs/preact': specifier: ^5.0.0-beta.4 version: link:../../packages/integrations/preact astro: - specifier: ^6.0.0-beta.18 + specifier: ^6.0.0-beta.19 version: link:../../packages/astro preact: specifier: ^10.28.4 @@ -464,7 +464,7 @@ importers: specifier: ^1.0.0 version: 1.0.0(nanostores@1.1.1)(preact@10.28.4) astro: - specifier: ^6.0.0-beta.18 + specifier: ^6.0.0-beta.19 version: link:../../packages/astro nanostores: specifier: ^1.1.1 @@ -476,7 +476,7 @@ importers: examples/with-tailwindcss: dependencies: '@astrojs/mdx': - specifier: ^5.0.0-beta.10 + specifier: ^5.0.0-beta.11 version: link:../../packages/integrations/mdx '@tailwindcss/vite': specifier: ^4.2.1 @@ -485,7 +485,7 @@ importers: specifier: ^1.9.0 version: 1.9.0 astro: - specifier: ^6.0.0-beta.18 + specifier: ^6.0.0-beta.19 version: link:../../packages/astro canvas-confetti: specifier: ^1.9.4 @@ -497,7 +497,7 @@ importers: examples/with-vitest: dependencies: astro: - specifier: ^6.0.0-beta.18 + specifier: ^6.0.0-beta.19 version: link:../../packages/astro vitest: specifier: ^3.2.4 From 7ac43c713be0c69b8df0fdaaca1e85e022361216 Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Thu, 5 Mar 2026 11:06:32 +0100 Subject: [PATCH 2/6] feat(cli): use tinyclip for astro info (#15712) Co-authored-by: Emanuele Stoppa --- .changeset/breezy-schools-travel.md | 5 + packages/astro/package.json | 1 + packages/astro/src/cli/index.ts | 8 +- .../astro/src/cli/info/infra/cli-clipboard.ts | 95 ------------------- .../src/cli/info/infra/tinyclip-clipboard.ts | 40 ++++++++ packages/astro/test/units/cli/info.test.js | 49 ++-------- pnpm-lock.yaml | 8 ++ 7 files changed, 63 insertions(+), 143 deletions(-) create mode 100644 .changeset/breezy-schools-travel.md delete mode 100644 packages/astro/src/cli/info/infra/cli-clipboard.ts create mode 100644 packages/astro/src/cli/info/infra/tinyclip-clipboard.ts diff --git a/.changeset/breezy-schools-travel.md b/.changeset/breezy-schools-travel.md new file mode 100644 index 000000000000..17cde2d3aa5e --- /dev/null +++ b/.changeset/breezy-schools-travel.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Improves `astro info` by supporting more operating systems when copying the information to the clipboard. diff --git a/packages/astro/package.json b/packages/astro/package.json index e1f09d859c76..5ee556127278 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -158,6 +158,7 @@ "shiki": "^4.0.0", "smol-toml": "^1.6.0", "svgo": "^4.0.0", + "tinyclip": "^0.1.6", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tsconfck": "^3.1.6", diff --git a/packages/astro/src/cli/index.ts b/packages/astro/src/cli/index.ts index 76866a0d5d0b..7d1523bb7d94 100644 --- a/packages/astro/src/cli/index.ts +++ b/packages/astro/src/cli/index.ts @@ -102,7 +102,7 @@ async function runCommand(cmd: string, flags: yargs.Arguments) { { getPackageManager }, { StyledDebugInfoFormatter }, { ClackPrompt }, - { CliClipboard }, + { TinyclipClipboard }, { PassthroughTextStyler }, { infoCommand }, ] = await Promise.all([ @@ -115,7 +115,7 @@ async function runCommand(cmd: string, flags: yargs.Arguments) { import('./info/core/get-package-manager.js'), import('./info/infra/styled-debug-info-formatter.js'), import('./info/infra/clack-prompt.js'), - import('./info/infra/cli-clipboard.js'), + import('./info/infra/tinyclip-clipboard.js'), import('./infra/passthrough-text-styler.js'), import('./info/core/info.js'), ]); @@ -135,10 +135,8 @@ async function runCommand(cmd: string, flags: yargs.Arguments) { nodeVersionProvider, }); const prompt = new ClackPrompt({ force: flags.copy }); - const clipboard = new CliClipboard({ - commandExecutor, + const clipboard = new TinyclipClipboard({ logger, - operatingSystemProvider, prompt, }); diff --git a/packages/astro/src/cli/info/infra/cli-clipboard.ts b/packages/astro/src/cli/info/infra/cli-clipboard.ts deleted file mode 100644 index d23e9c13c1d5..000000000000 --- a/packages/astro/src/cli/info/infra/cli-clipboard.ts +++ /dev/null @@ -1,95 +0,0 @@ -import type { Logger } from '../../../core/logger/core.js'; -import type { CommandExecutor, OperatingSystemProvider } from '../../definitions.js'; -import type { Clipboard, Prompt } from '../definitions.js'; - -async function getExecInputForPlatform({ - platform, - commandExecutor, -}: { - commandExecutor: CommandExecutor; - platform: NodeJS.Platform; -}): Promise<[command: string, args?: Array] | null> { - if (platform === 'darwin') { - return ['pbcopy']; - } - if (platform === 'win32') { - return ['clip']; - } - // Unix: check if a supported command is installed - const unixCommands: Array<[string, Array]> = [ - ['xclip', ['-selection', 'clipboard']], - ['wl-copy', []], - ]; - for (const [unixCommand, unixArgs] of unixCommands) { - try { - const { stdout } = await commandExecutor.execute('which', [unixCommand]); - if (stdout.trim()) { - return [unixCommand, unixArgs]; - } - } catch { - continue; - } - } - return null; -} - -export class CliClipboard implements Clipboard { - readonly #operatingSystemProvider: OperatingSystemProvider; - readonly #commandExecutor: CommandExecutor; - readonly #logger: Logger; - readonly #prompt: Prompt; - - constructor({ - operatingSystemProvider, - commandExecutor, - logger, - prompt, - }: { - operatingSystemProvider: OperatingSystemProvider; - commandExecutor: CommandExecutor; - logger: Logger; - prompt: Prompt; - }) { - this.#operatingSystemProvider = operatingSystemProvider; - this.#commandExecutor = commandExecutor; - this.#logger = logger; - this.#prompt = prompt; - } - - async copy(text: string): Promise { - text = text.trim(); - const platform = this.#operatingSystemProvider.name; - const input = await getExecInputForPlatform({ - platform, - commandExecutor: this.#commandExecutor, - }); - if (!input) { - this.#logger.warn('SKIP_FORMAT', 'Clipboard command not found!'); - this.#logger.info('SKIP_FORMAT', 'Please manually copy the text above.'); - return; - } - - if ( - !(await this.#prompt.confirm({ - message: 'Copy to clipboard?', - defaultValue: true, - })) - ) { - return; - } - - try { - const [command, args] = input; - await this.#commandExecutor.execute(command, args, { - input: text, - stdio: ['pipe', 'ignore', 'ignore'], - }); - this.#logger.info('SKIP_FORMAT', 'Copied to clipboard!'); - } catch { - this.#logger.error( - 'SKIP_FORMAT', - 'Sorry, something went wrong! Please copy the text above manually.', - ); - } - } -} diff --git a/packages/astro/src/cli/info/infra/tinyclip-clipboard.ts b/packages/astro/src/cli/info/infra/tinyclip-clipboard.ts new file mode 100644 index 000000000000..85d4094fa052 --- /dev/null +++ b/packages/astro/src/cli/info/infra/tinyclip-clipboard.ts @@ -0,0 +1,40 @@ +import type { Logger } from '../../../core/logger/core.js'; +import type { Clipboard, Prompt } from '../definitions.js'; +import { writeText } from 'tinyclip'; + +export class TinyclipClipboard implements Clipboard { + readonly #logger: Logger; + readonly #prompt: Prompt; + + constructor({ + logger, + prompt, + }: { + logger: Logger; + prompt: Prompt; + }) { + this.#logger = logger; + this.#prompt = prompt; + } + + async copy(text: string): Promise { + if ( + !(await this.#prompt.confirm({ + message: 'Copy to clipboard?', + defaultValue: true, + })) + ) { + return; + } + + try { + await writeText(text.trim()); + this.#logger.info('SKIP_FORMAT', 'Copied to clipboard!'); + } catch { + this.#logger.error( + 'SKIP_FORMAT', + 'Sorry, something went wrong! Please copy the text above manually.', + ); + } + } +} diff --git a/packages/astro/test/units/cli/info.test.js b/packages/astro/test/units/cli/info.test.js index 960a4679b923..11bc424fc776 100644 --- a/packages/astro/test/units/cli/info.test.js +++ b/packages/astro/test/units/cli/info.test.js @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import { getPackageManager } from '../../../dist/cli/info/core/get-package-manager.js'; import { infoCommand } from '../../../dist/cli/info/core/info.js'; -import { CliClipboard } from '../../../dist/cli/info/infra/cli-clipboard.js'; +import { TinyclipClipboard } from '../../../dist/cli/info/infra/tinyclip-clipboard.js'; import { CliDebugInfoProvider } from '../../../dist/cli/info/infra/cli-debug-info-provider.js'; import { DevDebugInfoProvider } from '../../../dist/cli/info/infra/dev-debug-info-provider.js'; import { ProcessNodeVersionProvider } from '../../../dist/cli/info/infra/process-node-version-provider.js'; @@ -154,70 +154,33 @@ describe('CLI info', () => { }); describe('infra', () => { - describe('CliClipboard', () => { - it('aborts early if no copy command can be found', async () => { - const commandExecutor = new SpyCommandExecutor({ fail: true }); - const logger = new SpyLogger(); - const operatingSystemProvider = new FakeOperatingSystemProvider('aix'); - const prompt = new FakePrompt(true); - - const clipboard = new CliClipboard({ - commandExecutor, - logger, - operatingSystemProvider, - prompt, - }); - await clipboard.copy('foo bar'); - - assert.equal(commandExecutor.inputs.length, 2); - assert.equal(logger.logs[0].type, 'warn'); - assert.equal(logger.logs[0].message, 'Clipboard command not found!'); - assert.equal(logger.logs[1].type, 'info'); - assert.equal(logger.logs[1].message, 'Please manually copy the text above.'); - }); - + describe('TinyclipClipboard', () => { it('aborts if user does not confirm', async () => { - const commandExecutor = new SpyCommandExecutor(); const logger = new SpyLogger(); - const operatingSystemProvider = new FakeOperatingSystemProvider('win32'); const prompt = new FakePrompt(false); - const clipboard = new CliClipboard({ - commandExecutor, + const clipboard = new TinyclipClipboard({ logger, - operatingSystemProvider, prompt, }); const text = Date.now().toString(); await clipboard.copy(text); assert.equal(logger.logs.length, 0); - assert.equal(commandExecutor.inputs.length, 0); }); - it('copies correctly', async () => { - const commandExecutor = new SpyCommandExecutor(); + it('copies if user confirms', async () => { const logger = new SpyLogger(); - const operatingSystemProvider = new FakeOperatingSystemProvider('win32'); const prompt = new FakePrompt(true); - const clipboard = new CliClipboard({ - commandExecutor, + const clipboard = new TinyclipClipboard({ logger, - operatingSystemProvider, prompt, }); const text = Date.now().toString(); await clipboard.copy(text); - assert.equal(logger.logs[0].type, 'info'); - assert.equal(logger.logs[0].message, 'Copied to clipboard!'); - assert.equal(commandExecutor.inputs.length, 1); - assert.deepStrictEqual(commandExecutor.inputs[0], { - command: 'clip', - args: undefined, - input: text, - }); + assert.equal(logger.logs.length, 1); }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d30f781a572c..cdd9bf9da87f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -628,6 +628,9 @@ importers: svgo: specifier: ^4.0.0 version: 4.0.0 + tinyclip: + specifier: ^0.1.6 + version: 0.1.6 tinyexec: specifier: ^1.0.2 version: 1.0.2 @@ -15056,6 +15059,9 @@ packages: tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinyclip@0.1.6: + resolution: {integrity: sha512-LJE4R1yUcQJ0IvbirB1ZjB1SSE321OU2WnVKANOIlh4eLSXyxid0yNatS1iOUy18ATFbky2a85XM3AAbdrq2XQ==} + tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} @@ -25106,6 +25112,8 @@ snapshots: tinybench@2.9.0: {} + tinyclip@0.1.6: {} + tinyexec@0.3.2: {} tinyexec@1.0.2: {} From 89397517830fec1d5b40e57ba1e35db1eb5fee79 Mon Sep 17 00:00:00 2001 From: Emanuele Stoppa Date: Thu, 5 Mar 2026 10:12:44 +0000 Subject: [PATCH 3/6] fix(pooling): rendering and configuration (#15761) --- .changeset/rich-ghosts-pick.md | 5 + packages/astro/src/core/app/dev/pipeline.ts | 4 +- packages/astro/src/core/app/manifest.ts | 6 - packages/astro/src/core/app/types.ts | 2 +- packages/astro/src/core/base-pipeline.ts | 5 +- packages/astro/src/core/build/pipeline.ts | 4 +- .../astro/src/core/config/schemas/base.ts | 2 +- .../src/runtime/server/html-string-cache.ts | 52 +++++ .../src/runtime/server/render/queue/pool.ts | 180 ++---------------- packages/astro/src/types/public/config.ts | 4 +- .../astro/src/vite-plugin-app/pipeline.ts | 4 +- .../render/queue-pool-content-cache.test.js | 178 ----------------- .../render/queue-pool-prewarming.test.js | 165 ---------------- 13 files changed, 88 insertions(+), 523 deletions(-) create mode 100644 .changeset/rich-ghosts-pick.md delete mode 100644 packages/astro/test/units/render/queue-pool-content-cache.test.js delete mode 100644 packages/astro/test/units/render/queue-pool-prewarming.test.js diff --git a/.changeset/rich-ghosts-pick.md b/.changeset/rich-ghosts-pick.md new file mode 100644 index 000000000000..befcaee78064 --- /dev/null +++ b/.changeset/rich-ghosts-pick.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fixes an issue where it wasn't possible to set `experimental.queuedRendering.poolSize` to `0`. diff --git a/packages/astro/src/core/app/dev/pipeline.ts b/packages/astro/src/core/app/dev/pipeline.ts index 7e1b44cd7c67..846740729c3b 100644 --- a/packages/astro/src/core/app/dev/pipeline.ts +++ b/packages/astro/src/core/app/dev/pipeline.ts @@ -50,7 +50,9 @@ export class NonRunnablePipeline extends Pipeline { ); if (queueRenderingEnabled(manifest.experimentalQueuedRendering)) { pipeline.nodePool = newNodePool(manifest.experimentalQueuedRendering!); - pipeline.htmlStringCache = new HTMLStringCache(1000); // Use default size + if (manifest.experimentalQueuedRendering!.contentCache) { + pipeline.htmlStringCache = new HTMLStringCache(1000); + } } return pipeline; } diff --git a/packages/astro/src/core/app/manifest.ts b/packages/astro/src/core/app/manifest.ts index 794582a0e62f..c6010cce3bce 100644 --- a/packages/astro/src/core/app/manifest.ts +++ b/packages/astro/src/core/app/manifest.ts @@ -135,12 +135,6 @@ export function queuePoolSize( ): number { return config?.poolSize ?? 1000; } -export function queueContentCache( - config: NonNullable, -): boolean { - return config?.contentCache ?? false; -} - export function queueRenderingEnabled(config: SSRManifest['experimentalQueuedRendering']): boolean { return config?.enabled ?? false; } diff --git a/packages/astro/src/core/app/types.ts b/packages/astro/src/core/app/types.ts index 45e63e0d7217..b96f65f28365 100644 --- a/packages/astro/src/core/app/types.ts +++ b/packages/astro/src/core/app/types.ts @@ -79,7 +79,7 @@ export type SSRManifest = { enabled: boolean; /** Node pool size for memory reuse (default: 1000, set to 0 to disable pooling) */ poolSize?: number; - /** Whether to enable HTMLString caching (default: true) */ + /** Whether to enable HTMLString caching for deduplicating repeated HTML fragments (default: true) */ contentCache?: boolean; }; assetsPrefix?: AssetsPrefix; diff --git a/packages/astro/src/core/base-pipeline.ts b/packages/astro/src/core/base-pipeline.ts index a93b37c3ec3d..b95ab98f1fcf 100644 --- a/packages/astro/src/core/base-pipeline.ts +++ b/packages/astro/src/core/base-pipeline.ts @@ -93,7 +93,6 @@ export abstract class Pipeline { if (manifest.experimentalQueuedRendering.enabled) { this.nodePool = this.createNodePool( manifest.experimentalQueuedRendering.poolSize ?? 1000, - manifest.experimentalQueuedRendering.contentCache ?? false, false, ); if (manifest.experimentalQueuedRendering.contentCache) { @@ -269,8 +268,8 @@ export abstract class Pipeline { } } - public createNodePool(poolSize: number, contentCache: boolean, stats: boolean): NodePool { - return new NodePool(poolSize, contentCache, stats); + public createNodePool(poolSize: number, stats: boolean): NodePool { + return new NodePool(poolSize, stats); } public createStringCache(): HTMLStringCache { diff --git a/packages/astro/src/core/build/pipeline.ts b/packages/astro/src/core/build/pipeline.ts index 2ce947aa52d2..4091c74c9a24 100644 --- a/packages/astro/src/core/build/pipeline.ts +++ b/packages/astro/src/core/build/pipeline.ts @@ -87,7 +87,9 @@ export class BuildPipeline extends Pipeline { super(logger, manifest, 'production', manifest.renderers, resolve, manifest.serverLike); if (queueRenderingEnabled(this.manifest.experimentalQueuedRendering)) { this.nodePool = newNodePool(this.manifest.experimentalQueuedRendering!); - this.htmlStringCache = new HTMLStringCache(1000); // Use default size + if (this.manifest.experimentalQueuedRendering!.contentCache) { + this.htmlStringCache = new HTMLStringCache(1000); + } } } diff --git a/packages/astro/src/core/config/schemas/base.ts b/packages/astro/src/core/config/schemas/base.ts index b19b361dbbe4..24ba309f311d 100644 --- a/packages/astro/src/core/config/schemas/base.ts +++ b/packages/astro/src/core/config/schemas/base.ts @@ -509,7 +509,7 @@ export const AstroConfigSchema = z.object({ queuedRendering: z .object({ enabled: z.boolean().optional().prefault(false), - poolSize: z.number().int().positive().optional(), + poolSize: z.number().int().nonnegative().optional(), contentCache: z.boolean().optional(), }) .optional() diff --git a/packages/astro/src/runtime/server/html-string-cache.ts b/packages/astro/src/runtime/server/html-string-cache.ts index d22df5bd0039..34a8b6911559 100644 --- a/packages/astro/src/runtime/server/html-string-cache.ts +++ b/packages/astro/src/runtime/server/html-string-cache.ts @@ -19,6 +19,7 @@ export class HTMLStringCache { constructor(maxSize = 1000) { this.maxSize = maxSize; + this.warm(COMMON_HTML_PATTERNS); } /** @@ -65,6 +66,20 @@ export class HTMLStringCache { return this.cache.size; } + /** + * Pre-warms the cache with common HTML patterns. + * This ensures first-render cache hits for frequently used tags. + * + * @param patterns - Array of HTML strings to pre-cache + */ + warm(patterns: string[]): void { + for (const pattern of patterns) { + if (!this.cache.has(pattern)) { + this.cache.set(pattern, new HTMLString(pattern)); + } + } + } + /** * Clear the entire cache */ @@ -72,3 +87,40 @@ export class HTMLStringCache { this.cache.clear(); } } + +/** + * Common HTML patterns that appear frequently in Astro pages. + * Pre-warming the cache with these patterns ensures first-render cache hits. + */ +export const COMMON_HTML_PATTERNS: string[] = [ + // Structural elements + '
', '
', + '', '', + '

', '

', + '
', '
', + '
', '
', + '
', '
', + '
', '
', + '', + '
', '
', + '', + // List elements + '
    ', '
', + '
    ', '
', + '
  • ', '
  • ', + // Void/self-closing elements + '
    ', '
    ', + '
    ', '
    ', + // Heading elements + '

    ', '

    ', + '

    ', '

    ', + '

    ', '

    ', + '

    ', '

    ', + // Inline elements + '', '', + '', '', + '', '', + '', '', + // Common whitespace + ' ', '\n', +]; diff --git a/packages/astro/src/runtime/server/render/queue/pool.ts b/packages/astro/src/runtime/server/render/queue/pool.ts index 3af10862ee28..5e5068eb2ab4 100644 --- a/packages/astro/src/runtime/server/render/queue/pool.ts +++ b/packages/astro/src/runtime/server/render/queue/pool.ts @@ -6,7 +6,7 @@ import type { InstructionNode, } from './types.js'; import type { SSRManifest } from '../../../../core/app/types.js'; -import { queueContentCache, queuePoolSize } from '../../../../core/app/manifest.js'; +import { queuePoolSize } from '../../../../core/app/manifest.js'; /** * Raw statistics tracked by the node pool. @@ -20,10 +20,6 @@ export interface PoolStats { released: number; /** Number of nodes that couldn't be returned (pool was full) */ releasedDropped: number; - /** Number of times content cache returned a cached node */ - contentCacheHit: number; - /** Number of times content cache had to create and cache a new node */ - contentCacheMiss: number; } /** @@ -41,85 +37,50 @@ export interface PoolStatsReport extends PoolStats { /** * Object pool for `QueueNode` instances to reduce allocations and GC pressure. - * Nodes are acquired from the pool, used during queue building, and can be - * released back to the pool for reuse across renders. * - * This significantly reduces memory allocation overhead when building large queues. + * Uses type-aware sub-pools so that released nodes are reused by the same + * node type, preserving V8 hidden classes and avoiding shape transitions. + * Nodes are acquired from the pool, used during queue building, and released + * back to the pool for reuse across renders. + * + * String deduplication is handled separately by `HTMLStringCache`. */ export class NodePool { private textPool: TextNode[] = []; private htmlStringPool: HtmlStringNode[] = []; private componentPool: ComponentNode[] = []; private instructionPool: InstructionNode[] = []; - private contentCache = new Map(); public readonly maxSize: number; private readonly enableStats: boolean; - private readonly enableContentCache: boolean; private stats: PoolStats = { acquireFromPool: 0, acquireNew: 0, released: 0, releasedDropped: 0, - contentCacheHit: 0, - contentCacheMiss: 0, }; /** * Creates a new object pool for queue nodes. * - * @param maxSize - Maximum number of nodes to keep in the pool (default: 1000) + * @param maxSize - Maximum number of nodes to keep in the pool (default: 1000). + * The cap is shared across all typed sub-pools. * @param enableStats - Enable statistics tracking (default: false for performance) - * @param enableContentCache - Enable content-aware caching for text/HTML nodes (default: true) */ - constructor(maxSize = 1000, enableContentCache = false, enableStats = false) { + constructor(maxSize = 1000, enableStats = false) { this.maxSize = maxSize; this.enableStats = enableStats; - this.enableContentCache = enableContentCache; - if (maxSize > 0) { - // Warm up cache only if the pool size is greater than 0. We treat zero as if there's no pool. - this.warmCache([...COMMON_HTML_PATTERNS]); - } } /** * Acquires a queue node from the pool or creates a new one if the pool is empty. - * Supports content-aware caching for text and HTML-string nodes. + * Pops from the type-specific sub-pool to reuse an existing object when available. * - * @param type - The type of queue node to create - * @param content - Optional content for content-aware caching (text or HTML) + * @param type - The type of queue node to acquire + * @param content - Optional content to set on the node (for text or html-string types) * @returns A queue node ready to be populated with data */ acquire(type: QueueNode['type'], content?: string): QueueNode { - if ( - this.enableContentCache && - content !== undefined && - (type === 'text' || type === 'html-string') - ) { - const cacheKey = `${type}:${content}`; - const cached = this.contentCache.get(cacheKey); - - if (cached) { - if (this.enableStats) { - this.stats.contentCacheHit = this.stats.contentCacheHit + 1; - } - // Clone the cached node to avoid shared state - return this.cloneNode(cached); - } - - // Cache miss - create template node and cache it - if (this.enableStats) { - this.stats.contentCacheMiss = this.stats.contentCacheMiss + 1; - } - - // Create immutable template node for caching - const template = this.createNode(type, content); - this.contentCache.set(cacheKey, template); - - // Return a clone for use - return this.cloneNode(template); - } - - // Standard pooling - pop from the type-specific sub-pool and reuse the object + // Pop from the type-specific sub-pool and reuse the object const pooledNode = this.popFromTypedPool(type); if (pooledNode) { @@ -156,23 +117,6 @@ export class NodePool { } } - /** - * Clones a cached node to avoid shared state. - * Helper method to reduce branching in acquire(). - */ - private cloneNode(node: QueueNode): QueueNode { - switch (node.type) { - case 'text': - return { type: 'text', content: node.content }; - case 'html-string': - return { type: 'html-string', html: node.html }; - case 'component': - return { type: 'component', instance: node.instance }; - case 'instruction': - return { type: 'instruction', instruction: node.instruction }; - } - } - /** * Pops a node from the type-specific sub-pool. * Returns undefined if the sub-pool for the requested type is empty. @@ -287,28 +231,6 @@ export class NodePool { this.instructionPool.length = 0; } - /** - * Pre-warms the content cache with common patterns. - * This can improve cache hit rates during builds by pre-populating frequently used patterns. - * - * @param patterns - Array of {type, content} objects to pre-cache - */ - warmCache(patterns: Array<{ type: 'text' | 'html-string'; content: string }>): void { - if (!this.enableContentCache) return; - - for (const { type, content } of patterns) { - // Only warm cache if not already present - const cacheKey = `${type}:${content}`; - if (!this.contentCache.has(cacheKey)) { - const template: QueueNode = - type === 'text' - ? { type: 'text', content: content } - : { type: 'html-string', html: content }; - this.contentCache.set(cacheKey, template); - } - } - } - /** * Gets the current total number of nodes across all typed sub-pools. * Useful for monitoring pool usage and tuning maxSize. @@ -346,80 +268,10 @@ export class NodePool { acquireNew: 0, released: 0, releasedDropped: 0, - contentCacheHit: 0, - contentCacheMiss: 0, }; } } -/** - * Common HTML patterns that appear frequently in Astro pages. - * Pre-warming the cache with these patterns improves hit rates during builds. - */ -export const COMMON_HTML_PATTERNS = [ - // Structural elements - { type: 'html-string' as const, content: '
    ' }, - { type: 'html-string' as const, content: '
    ' }, - { type: 'html-string' as const, content: '' }, - { type: 'html-string' as const, content: '' }, - { type: 'html-string' as const, content: '

    ' }, - { type: 'html-string' as const, content: '

    ' }, - { type: 'html-string' as const, content: '
    ' }, - { type: 'html-string' as const, content: '
    ' }, - { type: 'html-string' as const, content: '
    ' }, - { type: 'html-string' as const, content: '
    ' }, - { type: 'html-string' as const, content: '
    ' }, - { type: 'html-string' as const, content: '
    ' }, - { type: 'html-string' as const, content: '
    ' }, - { type: 'html-string' as const, content: '
    ' }, - { type: 'html-string' as const, content: '' }, - { type: 'html-string' as const, content: '
    ' }, - { type: 'html-string' as const, content: '
    ' }, - { type: 'html-string' as const, content: '' }, - - // List elements - { type: 'html-string' as const, content: '
      ' }, - { type: 'html-string' as const, content: '
    ' }, - { type: 'html-string' as const, content: '
      ' }, - { type: 'html-string' as const, content: '
    ' }, - { type: 'html-string' as const, content: '
  • ' }, - { type: 'html-string' as const, content: '
  • ' }, - - // Void/self-closing elements - { type: 'html-string' as const, content: '
    ' }, - { type: 'html-string' as const, content: '
    ' }, - { type: 'html-string' as const, content: '
    ' }, - { type: 'html-string' as const, content: '
    ' }, - - // Heading elements - { type: 'html-string' as const, content: '

    ' }, - { type: 'html-string' as const, content: '

    ' }, - { type: 'html-string' as const, content: '

    ' }, - { type: 'html-string' as const, content: '

    ' }, - { type: 'html-string' as const, content: '

    ' }, - { type: 'html-string' as const, content: '

    ' }, - { type: 'html-string' as const, content: '

    ' }, - { type: 'html-string' as const, content: '

    ' }, - - // Inline elements - { type: 'html-string' as const, content: '' }, - { type: 'html-string' as const, content: '' }, - { type: 'html-string' as const, content: '' }, - { type: 'html-string' as const, content: '' }, - { type: 'html-string' as const, content: '' }, - { type: 'html-string' as const, content: '' }, - { type: 'html-string' as const, content: '' }, - { type: 'html-string' as const, content: '' }, - - // Common whitespace/formatting - { type: 'text' as const, content: ' ' }, - { type: 'text' as const, content: '\n' }, - { type: 'html-string' as const, content: ' ' }, - { type: 'html-string' as const, content: '\n' }, -] as const; - /** * Returns an instance of the `NodePool` based on its configuration. * @param config - The queued rendering configuration from the SSR manifest @@ -428,7 +280,5 @@ export function newNodePool( config: NonNullable, ): NodePool { const poolSize = queuePoolSize(config); - const cache = queueContentCache(config); - - return new NodePool(poolSize, cache); + return new NodePool(poolSize); } diff --git a/packages/astro/src/types/public/config.ts b/packages/astro/src/types/public/config.ts index bd1e37cc49d2..8d1e99607437 100644 --- a/packages/astro/src/types/public/config.ts +++ b/packages/astro/src/types/public/config.ts @@ -2961,7 +2961,9 @@ export interface AstroUserConfig< * @default `false` * @version 6.0.0 * @description - * Allows to enable the caching of node contents when rendering the same page. + * Enables HTMLString caching to deduplicate repeated HTML fragments during rendering. + * When enabled, identical HTML strings (e.g., repeated `
  • ` tags) share a single + * `HTMLString` object instead of creating a new wrapper per occurrence. * This caching is disabled for dynamic pages. */ contentCache?: boolean; diff --git a/packages/astro/src/vite-plugin-app/pipeline.ts b/packages/astro/src/vite-plugin-app/pipeline.ts index f103a5138f16..792a10b069a1 100644 --- a/packages/astro/src/vite-plugin-app/pipeline.ts +++ b/packages/astro/src/vite-plugin-app/pipeline.ts @@ -73,7 +73,9 @@ export class RunnablePipeline extends Pipeline { pipeline.routesList = manifestData; if (queueRenderingEnabled(manifest.experimentalQueuedRendering)) { pipeline.nodePool = newNodePool(manifest.experimentalQueuedRendering!); - pipeline.htmlStringCache = new HTMLStringCache(1000); // Use default size + if (manifest.experimentalQueuedRendering!.contentCache) { + pipeline.htmlStringCache = new HTMLStringCache(1000); + } } return pipeline; } diff --git a/packages/astro/test/units/render/queue-pool-content-cache.test.js b/packages/astro/test/units/render/queue-pool-content-cache.test.js deleted file mode 100644 index e68aef174d51..000000000000 --- a/packages/astro/test/units/render/queue-pool-content-cache.test.js +++ /dev/null @@ -1,178 +0,0 @@ -import { describe, it } from 'node:test'; -import { strictEqual, notStrictEqual } from 'node:assert'; -import { NodePool } from '../../../dist/runtime/server/render/queue/pool.js'; - -describe('NodePool - Content-Aware Caching', () => { - it('should cache text nodes by content', () => { - const pool = new NodePool(1000, true, true); // Enable content cache - - // First acquisition - cache miss - const node1 = pool.acquire('text', 'Hello'); - strictEqual(node1.type, 'text'); - strictEqual(node1.content, 'Hello'); - - // Second acquisition - should be cache hit - const node2 = pool.acquire('text', 'Hello'); - strictEqual(node2.type, 'text'); - strictEqual(node2.content, 'Hello'); - - // Should be different object instances (cloned) - notStrictEqual(node1, node2); - - // Verify stats - const stats = pool.getStats(); - strictEqual(stats.contentCacheHit, 1); - strictEqual(stats.contentCacheMiss, 1); - }); - - it('should cache html-string nodes by content', () => { - const pool = new NodePool(1000, true, true); - - // First acquisition - const node1 = pool.acquire('html-string', '
    Test
    '); - strictEqual(node1.type, 'html-string'); - strictEqual(node1.html, '
    Test
    '); - - // Second acquisition - cache hit - const node2 = pool.acquire('html-string', '
    Test
    '); - strictEqual(node2.type, 'html-string'); - strictEqual(node2.html, '
    Test
    '); - - // Different instances - notStrictEqual(node1, node2); - - const stats = pool.getStats(); - strictEqual(stats.contentCacheHit, 1); - strictEqual(stats.contentCacheMiss, 1); - }); - - it('should differentiate between text and html-string with same content', () => { - const pool = new NodePool(1000, true, true); - - // Use custom content not in COMMON_HTML_PATTERNS - const textNode = pool.acquire('text', ''); - const htmlNode = pool.acquire('html-string', ''); - - strictEqual(textNode.type, 'text'); - strictEqual(textNode.content, ''); - strictEqual(htmlNode.type, 'html-string'); - strictEqual(htmlNode.html, ''); - - // Both should be cache misses (different types) - const stats = pool.getStats(); - strictEqual(stats.contentCacheHit, 0); - strictEqual(stats.contentCacheMiss, 2); - }); - - it('should allow modification of cloned nodes without affecting cache', () => { - const pool = new NodePool(1000, true, true); - - // Get first node - const node1 = pool.acquire('text', 'Shared'); - node1.parent = { type: 'element' }; - node1.position = 5; - node1.originalValue = 'original1'; - - // Get second node - should not have modifications from node1 - const node2 = pool.acquire('text', 'Shared'); - strictEqual(node2.parent, undefined); - strictEqual(node2.position, undefined); - strictEqual(node2.originalValue, undefined); - strictEqual(node2.content, 'Shared'); // Content preserved - - // Modify node2 - node2.parent = { type: 'fragment' }; - node2.position = 10; - - // Get third node - should not have modifications from node2 - const node3 = pool.acquire('text', 'Shared'); - strictEqual(node3.parent, undefined); - strictEqual(node3.position, undefined); - strictEqual(node3.content, 'Shared'); - }); - - it('should handle empty string content', () => { - const pool = new NodePool(1000, true, true); - - const node1 = pool.acquire('text', ''); - const node2 = pool.acquire('text', ''); - - strictEqual(node1.content, ''); - strictEqual(node2.content, ''); - notStrictEqual(node1, node2); - - const stats = pool.getStats(); - strictEqual(stats.contentCacheHit, 1); - }); - - it('should work when content caching is disabled', () => { - const pool = new NodePool(1000, true, false); // Disable content cache - - pool.acquire('text', 'Hello'); - pool.acquire('text', 'Hello'); - - // Should use standard pooling (not content cache) - const stats = pool.getStats(); - strictEqual(stats.contentCacheHit, 0); - strictEqual(stats.contentCacheMiss, 0); - }); - - it('should not cache component or instruction nodes', () => { - const pool = new NodePool(1000, true, true); - - // These types don't support content caching - pool.acquire('component'); - pool.acquire('component'); - pool.acquire('instruction'); - pool.acquire('instruction'); - - // Should use standard pooling (no content cache) - const stats = pool.getStats(); - strictEqual(stats.contentCacheHit, 0); - strictEqual(stats.contentCacheMiss, 0); - // All 4 nodes created new (pool starts empty) - strictEqual(stats.acquireNew, 4); - }); - - it('should handle large content strings', () => { - const pool = new NodePool(1000, true, true); - - const largeContent = 'x'.repeat(10000); - const node1 = pool.acquire('text', largeContent); - const node2 = pool.acquire('text', largeContent); - - strictEqual(node1.content, largeContent); - strictEqual(node2.content, largeContent); - notStrictEqual(node1, node2); - - const stats = pool.getStats(); - strictEqual(stats.contentCacheHit, 1); - }); - - it('should cache common HTML patterns', () => { - const pool = new NodePool(1000, true, true); - - // Use custom patterns not in COMMON_HTML_PATTERNS - const patterns = ['
    ', '', '', '
    ', '']; - - // First pass - all cache misses - for (const pattern of patterns) { - const node = pool.acquire('html-string', pattern); - strictEqual(node.html, pattern); - } - - let stats = pool.getStats(); - strictEqual(stats.contentCacheMiss, 5); - strictEqual(stats.contentCacheHit, 0); - - // Second pass - all cache hits - for (const pattern of patterns) { - const node = pool.acquire('html-string', pattern); - strictEqual(node.html, pattern); - } - - stats = pool.getStats(); - strictEqual(stats.contentCacheMiss, 5); - strictEqual(stats.contentCacheHit, 5); - }); -}); diff --git a/packages/astro/test/units/render/queue-pool-prewarming.test.js b/packages/astro/test/units/render/queue-pool-prewarming.test.js deleted file mode 100644 index 211c11c955e6..000000000000 --- a/packages/astro/test/units/render/queue-pool-prewarming.test.js +++ /dev/null @@ -1,165 +0,0 @@ -import { describe, it } from 'node:test'; -import { strictEqual, ok } from 'node:assert'; -import { NodePool, COMMON_HTML_PATTERNS } from '../../../dist/runtime/server/render/queue/pool.js'; - -describe('NodePool - Cache Pre-warming', () => { - it('should warm cache with provided patterns', () => { - const pool = new NodePool(1000, true, true); - - const patterns = [ - { type: 'text', content: 'Hello' }, - { type: 'html-string', content: '
    ' }, - { type: 'html-string', content: '
    ' }, - ]; - - pool.warmCache(patterns); - - // First acquisition should be cache hit (already warmed) - pool.acquire('text', 'Hello'); - pool.acquire('html-string', '
    '); - pool.acquire('html-string', '
    '); - - const stats = pool.getStats(); - strictEqual(stats.contentCacheHit, 3, 'All 3 patterns should be cache hits'); - strictEqual(stats.contentCacheMiss, 0, 'No cache misses expected'); - }); - - it('should not warm cache when content caching is disabled', () => { - const pool = new NodePool(1000, true, false); // Disable content cache - - const patterns = [{ type: 'text', content: 'Hello' }]; - - pool.warmCache(patterns); // Should be no-op - - pool.acquire('text', 'Hello'); - const stats = pool.getStats(); - - strictEqual(stats.contentCacheHit, 0, 'Content caching disabled, no hits'); - }); - - it('should not duplicate patterns already in cache', () => { - const pool = new NodePool(1000, true, true); - - // Acquire first to populate cache - pool.acquire('text', 'Test'); - - // Try to warm with same pattern - pool.warmCache([{ type: 'text', content: 'Test' }]); - - // Acquire again - should still be just 1 cache hit total (not 2) - pool.acquire('text', 'Test'); - - const stats = pool.getStats(); - strictEqual(stats.contentCacheHit, 1, 'Should not duplicate existing cache entries'); - strictEqual(stats.contentCacheMiss, 1, 'First acquire was cache miss'); - }); - - it('should include common HTML patterns', () => { - ok(COMMON_HTML_PATTERNS.length > 0, 'COMMON_HTML_PATTERNS should not be empty'); - - // Check for some expected patterns - const patterns = COMMON_HTML_PATTERNS.map((p) => p.content); - ok(patterns.includes('
    '), 'Should include
    '); - ok(patterns.includes('
    '), 'Should include
    '); - ok(patterns.includes('
    '), 'Should include
    '); - ok(patterns.includes(' '), 'Should include space'); - ok(patterns.includes('\n'), 'Should include newline'); - }); - - it.skip('should pre-warm global pool on initialization', () => { - // Global pool should already be warmed - // Try to acquire common patterns - should all be cache hits - // TODO: This test expects a globalNodePool export that doesn't exist - const stats1 = globalNodePool.getStats(); - const initialHits = stats1.contentCacheHit; - - globalNodePool.acquire('html-string', '
    '); - globalNodePool.acquire('html-string', '
    '); - globalNodePool.acquire('html-string', '
    '); - globalNodePool.acquire('text', ' '); - - const stats2 = globalNodePool.getStats(); - strictEqual( - stats2.contentCacheHit - initialHits, - 4, - 'All common patterns should be cache hits', - ); - }); - - it('should improve hit rate with pre-warming', () => { - // Both pools start with COMMON_HTML_PATTERNS pre-warmed (automatic in constructor) - // We test the benefit of warming ADDITIONAL custom patterns - const poolWithoutCustomWarm = new NodePool(1000, true, true); - const poolWithCustomWarm = new NodePool(1000, true, true); - - // Use custom patterns NOT in COMMON_HTML_PATTERNS - const customPatterns = [ - { type: 'html-string', content: '' }, - { type: 'html-string', content: '' }, - ]; - - // Pre-warm one pool with ADDITIONAL custom patterns - poolWithCustomWarm.warmCache(customPatterns); - - // Simulate page rendering with repeated custom patterns - for (let i = 0; i < 100; i++) { - poolWithoutCustomWarm.acquire('html-string', ''); - poolWithoutCustomWarm.acquire('html-string', ''); - poolWithCustomWarm.acquire('html-string', ''); - poolWithCustomWarm.acquire('html-string', ''); - } - - const stats1 = poolWithoutCustomWarm.getStats(); - const stats2 = poolWithCustomWarm.getStats(); - - // Without custom warming: first two custom patterns are misses (not in COMMON_HTML_PATTERNS) - strictEqual(stats1.contentCacheMiss, 2, 'Two custom patterns = 2 misses'); - strictEqual(stats1.contentCacheHit, 198, '198 hits after initial misses'); - - // With custom warming: all are hits (custom patterns were pre-warmed) - strictEqual(stats2.contentCacheMiss, 0, 'Custom pre-warmed = no misses'); - strictEqual(stats2.contentCacheHit, 200, 'All 200 are hits with custom pre-warming'); - }); - - it('should warm cache with void elements', () => { - const pool = new NodePool(1000, true, true); - - pool.warmCache([ - { type: 'html-string', content: '
    ' }, - { type: 'html-string', content: '
    ' }, - { type: 'html-string', content: '
    ' }, - { type: 'html-string', content: '
    ' }, - ]); - - // All should be cache hits - pool.acquire('html-string', '
    '); - pool.acquire('html-string', '
    '); - pool.acquire('html-string', '
    '); - pool.acquire('html-string', '
    '); - - const stats = pool.getStats(); - strictEqual(stats.contentCacheHit, 4); - strictEqual(stats.contentCacheMiss, 0); - }); - - it('should warm cache with text patterns', () => { - const pool = new NodePool(1000, true, true); - - pool.warmCache([ - { type: 'text', content: 'Read more' }, - { type: 'text', content: 'Continue reading' }, - { type: 'text', content: ' ' }, - { type: 'text', content: '\n' }, - ]); - - // All should be cache hits - pool.acquire('text', 'Read more'); - pool.acquire('text', 'Continue reading'); - pool.acquire('text', ' '); - pool.acquire('text', '\n'); - - const stats = pool.getStats(); - strictEqual(stats.contentCacheHit, 4); - strictEqual(stats.contentCacheMiss, 0); - }); -}); From c808fd52106f2ac10ae9186f3e9b4faa644c5e1e Mon Sep 17 00:00:00 2001 From: Emanuele Stoppa Date: Thu, 5 Mar 2026 10:17:25 +0000 Subject: [PATCH 4/6] [ci] format --- .../src/runtime/server/html-string-cache.ts | 72 ++++++++++++------- 1 file changed, 48 insertions(+), 24 deletions(-) diff --git a/packages/astro/src/runtime/server/html-string-cache.ts b/packages/astro/src/runtime/server/html-string-cache.ts index 34a8b6911559..743e86910873 100644 --- a/packages/astro/src/runtime/server/html-string-cache.ts +++ b/packages/astro/src/runtime/server/html-string-cache.ts @@ -94,33 +94,57 @@ export class HTMLStringCache { */ export const COMMON_HTML_PATTERNS: string[] = [ // Structural elements - '
    ', '
    ', - '', '', - '

    ', '

    ', - '
    ', '
    ', - '
    ', '
    ', - '
    ', '
    ', - '
    ', '
    ', - '', - '
    ', '
    ', - '', + '
    ', + '
    ', + '', + '', + '

    ', + '

    ', + '
    ', + '
    ', + '
    ', + '
    ', + '
    ', + '
    ', + '
    ', + '
    ', + '', + '
    ', + '
    ', + '', // List elements - '
      ', '
    ', - '
      ', '
    ', - '
  • ', '
  • ', + '
      ', + '
    ', + '
      ', + '
    ', + '
  • ', + '
  • ', // Void/self-closing elements - '
    ', '
    ', - '
    ', '
    ', + '
    ', + '
    ', + '
    ', + '
    ', // Heading elements - '

    ', '

    ', - '

    ', '

    ', - '

    ', '

    ', - '

    ', '

    ', + '

    ', + '

    ', + '

    ', + '

    ', + '

    ', + '

    ', + '

    ', + '

    ', // Inline elements - '', '', - '', '', - '', '', - '', '', + '', + '', + '', + '', + '', + '', + '', + '', // Common whitespace - ' ', '\n', + ' ', + '\n', ]; From 4e7f3e8e6849c314a0ab031ebd7f23fb982f0529 Mon Sep 17 00:00:00 2001 From: "Ocavue (Jiajin Wen)" Date: Fri, 6 Mar 2026 01:01:49 +1100 Subject: [PATCH 5/6] feat: identify different JSX frameworks during SSR (#15700) * feat: identify different JSX frameworks during SSR * add create filter * Fix typo in small-papayas-notice.md * add include/exclude to react virtual module * add filter to react/server.ts * add changeset * rename some changeset files * fix test case * add better error message * chore: trigger ci * Update jsx-include-exclude-react.md Co-authored-by: Sarah Rainsberger <5098874+sarah11918@users.noreply.github.com> * Update jsx-include-exclude-preact.md Co-authored-by: Sarah Rainsberger <5098874+sarah11918@users.noreply.github.com> * update changeset Updates the internal logic during SSR by providing additional metadata for UI framework integrations. --------- Co-authored-by: Sarah Rainsberger <5098874+sarah11918@users.noreply.github.com> --- .changeset/astro-ssr-check-metadata.md | 5 + .changeset/internal-create-filter.md | 5 + .changeset/jsx-include-exclude-preact.md | 5 + .changeset/jsx-include-exclude-react.md | 5 + .../src/runtime/server/render/component.ts | 15 +- .../multiple-jsx-renderers/astro.config.mjs | 4 + .../multiple-jsx-renderers/package.json | 7 + .../renderers/meow/index.mjs | 52 ++++++ .../renderers/meow/meow-client.mjs | 13 ++ .../renderers/meow/meow-server.mjs | 29 ++++ .../renderers/meow/types.d.ts | 7 + .../renderers/woof/index.mjs | 52 ++++++ .../renderers/woof/types.d.ts | 7 + .../renderers/woof/woof-client.mjs | 13 ++ .../renderers/woof/woof-server.mjs | 29 ++++ .../src/components/MeowCounter.meow.jsx | 3 + .../src/components/WoofCounter.woof.jsx | 3 + .../src/pages/client-load.astro | 16 ++ .../src/pages/client-only.astro | 16 ++ .../src/pages/ssr.astro | 16 ++ .../src/components/Nested.astro | 2 +- .../src/components/{ => react}/Nested.jsx | 0 .../astro/test/multiple-jsx-renderers.test.js | 164 ++++++++++++++++++ packages/integrations/preact/env.d.ts | 4 + packages/integrations/preact/package.json | 2 + packages/integrations/preact/src/index.ts | 38 +++- packages/integrations/preact/src/server.ts | 16 +- packages/integrations/preact/src/types.ts | 7 + packages/integrations/preact/tsconfig.json | 2 +- packages/integrations/react/env.d.ts | 8 +- packages/integrations/react/package.json | 2 + packages/integrations/react/src/index.ts | 19 +- packages/integrations/react/src/server.ts | 8 + packages/integrations/react/src/types.ts | 9 + packages/internal-helpers/package.json | 10 +- .../internal-helpers/src/create-filter.ts | 61 +++++++ .../test/create-filter.test.js | 86 +++++++++ pnpm-lock.yaml | 25 +++ 38 files changed, 747 insertions(+), 18 deletions(-) create mode 100644 .changeset/astro-ssr-check-metadata.md create mode 100644 .changeset/internal-create-filter.md create mode 100644 .changeset/jsx-include-exclude-preact.md create mode 100644 .changeset/jsx-include-exclude-react.md create mode 100644 packages/astro/test/fixtures/multiple-jsx-renderers/astro.config.mjs create mode 100644 packages/astro/test/fixtures/multiple-jsx-renderers/package.json create mode 100644 packages/astro/test/fixtures/multiple-jsx-renderers/renderers/meow/index.mjs create mode 100644 packages/astro/test/fixtures/multiple-jsx-renderers/renderers/meow/meow-client.mjs create mode 100644 packages/astro/test/fixtures/multiple-jsx-renderers/renderers/meow/meow-server.mjs create mode 100644 packages/astro/test/fixtures/multiple-jsx-renderers/renderers/meow/types.d.ts create mode 100644 packages/astro/test/fixtures/multiple-jsx-renderers/renderers/woof/index.mjs create mode 100644 packages/astro/test/fixtures/multiple-jsx-renderers/renderers/woof/types.d.ts create mode 100644 packages/astro/test/fixtures/multiple-jsx-renderers/renderers/woof/woof-client.mjs create mode 100644 packages/astro/test/fixtures/multiple-jsx-renderers/renderers/woof/woof-server.mjs create mode 100644 packages/astro/test/fixtures/multiple-jsx-renderers/src/components/MeowCounter.meow.jsx create mode 100644 packages/astro/test/fixtures/multiple-jsx-renderers/src/components/WoofCounter.woof.jsx create mode 100644 packages/astro/test/fixtures/multiple-jsx-renderers/src/pages/client-load.astro create mode 100644 packages/astro/test/fixtures/multiple-jsx-renderers/src/pages/client-only.astro create mode 100644 packages/astro/test/fixtures/multiple-jsx-renderers/src/pages/ssr.astro rename packages/astro/test/fixtures/static-build-frameworks/src/components/{ => react}/Nested.jsx (100%) create mode 100644 packages/astro/test/multiple-jsx-renderers.test.js create mode 100644 packages/integrations/preact/env.d.ts create mode 100644 packages/internal-helpers/src/create-filter.ts create mode 100644 packages/internal-helpers/test/create-filter.test.js diff --git a/.changeset/astro-ssr-check-metadata.md b/.changeset/astro-ssr-check-metadata.md new file mode 100644 index 000000000000..8c51662d7d8e --- /dev/null +++ b/.changeset/astro-ssr-check-metadata.md @@ -0,0 +1,5 @@ +--- +'astro': minor +--- + +Updates the internal logic during SSR by providing additional metadata for UI framework integrations. diff --git a/.changeset/internal-create-filter.md b/.changeset/internal-create-filter.md new file mode 100644 index 000000000000..2d53adb0af52 --- /dev/null +++ b/.changeset/internal-create-filter.md @@ -0,0 +1,5 @@ +--- +'@astrojs/internal-helpers': patch +--- + +Adds a fork of `createFilter` from `@rollup/pluginutils` without Node.js APIs. diff --git a/.changeset/jsx-include-exclude-preact.md b/.changeset/jsx-include-exclude-preact.md new file mode 100644 index 000000000000..867c68fe9f9b --- /dev/null +++ b/.changeset/jsx-include-exclude-preact.md @@ -0,0 +1,5 @@ +--- +'@astrojs/preact': patch +--- + +Improves how Preact components are identified when setting the `include` and/or `exclude` options in projects where multiple JSX frameworks are used together diff --git a/.changeset/jsx-include-exclude-react.md b/.changeset/jsx-include-exclude-react.md new file mode 100644 index 000000000000..c1dc6632ad7c --- /dev/null +++ b/.changeset/jsx-include-exclude-react.md @@ -0,0 +1,5 @@ +--- +'@astrojs/react': patch +--- + +Improves how React components are identified when setting the `include` and/or `exclude` options in projects where multiple JSX frameworks are used together diff --git a/packages/astro/src/runtime/server/render/component.ts b/packages/astro/src/runtime/server/render/component.ts index 4c1078ce60a0..bf943466b409 100644 --- a/packages/astro/src/runtime/server/render/component.ts +++ b/packages/astro/src/runtime/server/render/component.ts @@ -130,7 +130,7 @@ async function renderFrameworkComponent( let error; for (const r of renderers) { try { - if (await r.ssr.check.call({ result }, Component, props, children)) { + if (await r.ssr.check.call({ result }, Component, props, children, metadata)) { renderer = r; break; } @@ -160,7 +160,7 @@ async function renderFrameworkComponent( }; } } else { - // Attempt: use explicitly passed renderer name + // Attempt: use explicitly passed renderer name for official renderers if (metadata.hydrateArgs) { const rendererName = rendererAliases.has(metadata.hydrateArgs) ? rendererAliases.get(metadata.hydrateArgs) @@ -175,11 +175,19 @@ async function renderFrameworkComponent( if (!renderer && validRenderers.length === 1) { renderer = validRenderers[0]; } - // Attempt: can we guess the renderer from the export extension? + // Attempt: can we guess the official renderer from the export extension? if (!renderer) { const extname = metadata.componentUrl?.split('.').pop(); renderer = renderers.find(({ name }) => name === `@astrojs/${extname}` || name === extname); } + // Attempt: use explicitly passed renderer name for custom renderers. This is put + // last to avoid potential conflicts with the previous implementations. + if (!renderer && metadata.hydrateArgs) { + const rendererName = metadata.hydrateArgs; + if (typeof rendererName === 'string') { + renderer = renderers.find(({ name }) => name === rendererName); + } + } } let componentServerRenderEndTime; @@ -253,6 +261,7 @@ Please ensure that ${metadata.displayName}: 1. Does not unconditionally access browser-specific globals like \`window\` or \`document\`. If this is unavoidable, use the \`client:only\` hydration directive. 2. Does not conditionally return \`null\` or \`undefined\` when rendered on the server. +3. If using multiple JSX frameworks at the same time (e.g. React + Preact), pass the correct \`include\`/\`exclude\` options to integrations. If you're still stuck, please open an issue on GitHub or join us at https://astro.build/chat.`); } diff --git a/packages/astro/test/fixtures/multiple-jsx-renderers/astro.config.mjs b/packages/astro/test/fixtures/multiple-jsx-renderers/astro.config.mjs new file mode 100644 index 000000000000..ace9559edf47 --- /dev/null +++ b/packages/astro/test/fixtures/multiple-jsx-renderers/astro.config.mjs @@ -0,0 +1,4 @@ +import { defineConfig } from 'astro/config'; + +// Integrations are configured inline in multiple-jsx-renderers.test.js +export default defineConfig({}); diff --git a/packages/astro/test/fixtures/multiple-jsx-renderers/package.json b/packages/astro/test/fixtures/multiple-jsx-renderers/package.json new file mode 100644 index 000000000000..69d04b706738 --- /dev/null +++ b/packages/astro/test/fixtures/multiple-jsx-renderers/package.json @@ -0,0 +1,7 @@ +{ + "name": "@test/multiple-jsx-renderers", + "private": true, + "dependencies": { + "astro": "workspace:*" + } +} diff --git a/packages/astro/test/fixtures/multiple-jsx-renderers/renderers/meow/index.mjs b/packages/astro/test/fixtures/multiple-jsx-renderers/renderers/meow/index.mjs new file mode 100644 index 000000000000..d45000ee7695 --- /dev/null +++ b/packages/astro/test/fixtures/multiple-jsx-renderers/renderers/meow/index.mjs @@ -0,0 +1,52 @@ +// @ts-check +import * as devalue from 'devalue'; + +/** @param {{ include?: import('vite').FilterPattern, exclude?: import('vite').FilterPattern }} options */ +export default function ({ include, exclude } = {}) { + /** @type {import('astro').AstroIntegration} */ + const integration = { + name: 'meow', + hooks: { + 'astro:config:setup': ({ addRenderer, updateConfig }) => { + addRenderer({ + name: 'meow', + serverEntrypoint: new URL('./meow-server.mjs', import.meta.url).href, + clientEntrypoint: new URL('./meow-client.mjs', import.meta.url).href, + }); + + updateConfig({ + vite: { + plugins: [ + { + name: 'meow-jsx-transform', + transform(code, id) { + if (!id.endsWith('.jsx') && !id.endsWith('.tsx')) return null; + if (include && !id.endsWith('.meow.jsx')) return null; + return { code, map: null }; + }, + }, + { + name: 'meow-opts', + resolveId(id) { + if (id === 'astro:meow:opts') return '\0astro:meow:opts'; + }, + load(id) { + if (id === '\0astro:meow:opts') { + return { + code: `export default { + include: ${devalue.uneval(include ?? null)}, + exclude: ${devalue.uneval(exclude ?? null)} + }`, + }; + } + }, + }, + ], + }, + }); + }, + }, + }; + + return integration; +} diff --git a/packages/astro/test/fixtures/multiple-jsx-renderers/renderers/meow/meow-client.mjs b/packages/astro/test/fixtures/multiple-jsx-renderers/renderers/meow/meow-client.mjs new file mode 100644 index 000000000000..1417c29c5313 --- /dev/null +++ b/packages/astro/test/fixtures/multiple-jsx-renderers/renderers/meow/meow-client.mjs @@ -0,0 +1,13 @@ +// @ts-check + +/** + * @param {HTMLElement} parentElement + * @returns {(component: any, props: Record) => Promise} + */ +export default (parentElement) => async (Component, props) => { + const html = Component(props); + const div = document.createElement('div'); + div.setAttribute('data-renderer', 'meow'); + div.textContent = html; + parentElement.appendChild(div); +}; diff --git a/packages/astro/test/fixtures/multiple-jsx-renderers/renderers/meow/meow-server.mjs b/packages/astro/test/fixtures/multiple-jsx-renderers/renderers/meow/meow-server.mjs new file mode 100644 index 000000000000..ba6817cd2eb3 --- /dev/null +++ b/packages/astro/test/fixtures/multiple-jsx-renderers/renderers/meow/meow-server.mjs @@ -0,0 +1,29 @@ +// @ts-check +import { createFilter } from '@astrojs/internal-helpers/create-filter'; +import opts from 'astro:meow:opts'; + +const filter = (opts.include || opts.exclude) ? createFilter(opts.include, opts.exclude) : null; + +/** @type {import('astro').NamedSSRLoadedRendererValue} */ +const renderer = { + name: 'meow', + + async check(Component, _props, _slots, metadata) { + if (typeof Component !== 'function') return false; + if (filter && metadata?.componentUrl && !filter(metadata.componentUrl)) { + return false; + } + return true; + }, + + async renderToStaticMarkup(Component, props) { + const html = Component(props); + return Promise.resolve({ + html: `
    ${html}
    `, + }); + }, + + supportsAstroStaticSlot: true, +}; + +export default renderer; diff --git a/packages/astro/test/fixtures/multiple-jsx-renderers/renderers/meow/types.d.ts b/packages/astro/test/fixtures/multiple-jsx-renderers/renderers/meow/types.d.ts new file mode 100644 index 000000000000..e55353a7025a --- /dev/null +++ b/packages/astro/test/fixtures/multiple-jsx-renderers/renderers/meow/types.d.ts @@ -0,0 +1,7 @@ +declare module 'astro:meow:opts' { + const opts: { + include: import('vite').FilterPattern; + exclude: import('vite').FilterPattern; + }; + export default opts; +} diff --git a/packages/astro/test/fixtures/multiple-jsx-renderers/renderers/woof/index.mjs b/packages/astro/test/fixtures/multiple-jsx-renderers/renderers/woof/index.mjs new file mode 100644 index 000000000000..4f9cc014a68f --- /dev/null +++ b/packages/astro/test/fixtures/multiple-jsx-renderers/renderers/woof/index.mjs @@ -0,0 +1,52 @@ +// @ts-check +import * as devalue from 'devalue'; + +/** @param {{ include?: import('vite').FilterPattern, exclude?: import('vite').FilterPattern }} options */ +export default function ({ include, exclude } = {}) { + /** @type {import('astro').AstroIntegration} */ + const integration = { + name: 'woof', + hooks: { + 'astro:config:setup': ({ addRenderer, updateConfig }) => { + addRenderer({ + name: 'woof', + serverEntrypoint: new URL('./woof-server.mjs', import.meta.url).href, + clientEntrypoint: new URL('./woof-client.mjs', import.meta.url).href, + }); + + updateConfig({ + vite: { + plugins: [ + { + name: 'woof-jsx-transform', + transform(code, id) { + if (!id.endsWith('.jsx') && !id.endsWith('.tsx')) return null; + if (include && !id.endsWith('.woof.jsx')) return null; + return { code, map: null }; + }, + }, + { + name: 'woof-opts', + resolveId(id) { + if (id === 'astro:woof:opts') return '\0astro:woof:opts'; + }, + load(id) { + if (id === '\0astro:woof:opts') { + return { + code: `export default { + include: ${devalue.uneval(include ?? null)}, + exclude: ${devalue.uneval(exclude ?? null)} + }`, + }; + } + }, + }, + ], + }, + }); + }, + }, + }; + + return integration; +} diff --git a/packages/astro/test/fixtures/multiple-jsx-renderers/renderers/woof/types.d.ts b/packages/astro/test/fixtures/multiple-jsx-renderers/renderers/woof/types.d.ts new file mode 100644 index 000000000000..fc2d76e86a9d --- /dev/null +++ b/packages/astro/test/fixtures/multiple-jsx-renderers/renderers/woof/types.d.ts @@ -0,0 +1,7 @@ +declare module 'astro:woof:opts' { + const opts: { + include: import('vite').FilterPattern; + exclude: import('vite').FilterPattern; + }; + export default opts; +} diff --git a/packages/astro/test/fixtures/multiple-jsx-renderers/renderers/woof/woof-client.mjs b/packages/astro/test/fixtures/multiple-jsx-renderers/renderers/woof/woof-client.mjs new file mode 100644 index 000000000000..a8b557634719 --- /dev/null +++ b/packages/astro/test/fixtures/multiple-jsx-renderers/renderers/woof/woof-client.mjs @@ -0,0 +1,13 @@ +// @ts-check + +/** + * @param {HTMLElement} parentElement + * @returns {(component: any, props: Record) => Promise} + */ +export default (parentElement) => async (Component, props) => { + const html = Component(props); + const div = document.createElement('div'); + div.setAttribute('data-renderer', 'woof'); + div.textContent = html; + parentElement.appendChild(div); +}; diff --git a/packages/astro/test/fixtures/multiple-jsx-renderers/renderers/woof/woof-server.mjs b/packages/astro/test/fixtures/multiple-jsx-renderers/renderers/woof/woof-server.mjs new file mode 100644 index 000000000000..14ee931085ff --- /dev/null +++ b/packages/astro/test/fixtures/multiple-jsx-renderers/renderers/woof/woof-server.mjs @@ -0,0 +1,29 @@ +// @ts-check +import { createFilter } from '@astrojs/internal-helpers/create-filter'; +import opts from 'astro:woof:opts'; + +const filter = (opts.include || opts.exclude) ? createFilter(opts.include, opts.exclude) : null; + +/** @type {import('astro').NamedSSRLoadedRendererValue} */ +const renderer = { + name: 'woof', + + async check(Component, _props, _slots, metadata) { + if (typeof Component !== 'function') return false; + if (filter && metadata?.componentUrl && !filter(metadata.componentUrl)) { + return false; + } + return true; + }, + + async renderToStaticMarkup(Component, props) { + const html = Component(props); + return Promise.resolve({ + html: `
    ${html}
    `, + }); + }, + + supportsAstroStaticSlot: true, +}; + +export default renderer; diff --git a/packages/astro/test/fixtures/multiple-jsx-renderers/src/components/MeowCounter.meow.jsx b/packages/astro/test/fixtures/multiple-jsx-renderers/src/components/MeowCounter.meow.jsx new file mode 100644 index 000000000000..ff4774a5778b --- /dev/null +++ b/packages/astro/test/fixtures/multiple-jsx-renderers/src/components/MeowCounter.meow.jsx @@ -0,0 +1,3 @@ +export default function MeowCounter() { + return 'Meow Counter'; +} diff --git a/packages/astro/test/fixtures/multiple-jsx-renderers/src/components/WoofCounter.woof.jsx b/packages/astro/test/fixtures/multiple-jsx-renderers/src/components/WoofCounter.woof.jsx new file mode 100644 index 000000000000..c2dddc426692 --- /dev/null +++ b/packages/astro/test/fixtures/multiple-jsx-renderers/src/components/WoofCounter.woof.jsx @@ -0,0 +1,3 @@ +export default function WoofCounter() { + return 'Woof Counter'; +} diff --git a/packages/astro/test/fixtures/multiple-jsx-renderers/src/pages/client-load.astro b/packages/astro/test/fixtures/multiple-jsx-renderers/src/pages/client-load.astro new file mode 100644 index 000000000000..7286ae1d0604 --- /dev/null +++ b/packages/astro/test/fixtures/multiple-jsx-renderers/src/pages/client-load.astro @@ -0,0 +1,16 @@ +--- +import WoofCounter from '../components/WoofCounter.woof.jsx'; +import MeowCounter from '../components/MeowCounter.meow.jsx'; +--- + + + Client Load Test + +
    + +
    +
    + +
    + + diff --git a/packages/astro/test/fixtures/multiple-jsx-renderers/src/pages/client-only.astro b/packages/astro/test/fixtures/multiple-jsx-renderers/src/pages/client-only.astro new file mode 100644 index 000000000000..fd8b0abef498 --- /dev/null +++ b/packages/astro/test/fixtures/multiple-jsx-renderers/src/pages/client-only.astro @@ -0,0 +1,16 @@ +--- +import WoofCounter from '../components/WoofCounter.woof.jsx'; +import MeowCounter from '../components/MeowCounter.meow.jsx'; +--- + + + Client Only Test + +
    + +
    +
    + +
    + + diff --git a/packages/astro/test/fixtures/multiple-jsx-renderers/src/pages/ssr.astro b/packages/astro/test/fixtures/multiple-jsx-renderers/src/pages/ssr.astro new file mode 100644 index 000000000000..96a04db24851 --- /dev/null +++ b/packages/astro/test/fixtures/multiple-jsx-renderers/src/pages/ssr.astro @@ -0,0 +1,16 @@ +--- +import WoofCounter from '../components/WoofCounter.woof.jsx'; +import MeowCounter from '../components/MeowCounter.meow.jsx'; +--- + + + SSR Test + +
    + +
    +
    + +
    + + diff --git a/packages/astro/test/fixtures/static-build-frameworks/src/components/Nested.astro b/packages/astro/test/fixtures/static-build-frameworks/src/components/Nested.astro index 43a6233ee58f..6bc48d2fcc90 100644 --- a/packages/astro/test/fixtures/static-build-frameworks/src/components/Nested.astro +++ b/packages/astro/test/fixtures/static-build-frameworks/src/components/Nested.astro @@ -1,5 +1,5 @@ --- -import NestedCounter from './Nested.jsx'; +import NestedCounter from './react/Nested.jsx'; ---
    diff --git a/packages/astro/test/fixtures/static-build-frameworks/src/components/Nested.jsx b/packages/astro/test/fixtures/static-build-frameworks/src/components/react/Nested.jsx similarity index 100% rename from packages/astro/test/fixtures/static-build-frameworks/src/components/Nested.jsx rename to packages/astro/test/fixtures/static-build-frameworks/src/components/react/Nested.jsx diff --git a/packages/astro/test/multiple-jsx-renderers.test.js b/packages/astro/test/multiple-jsx-renderers.test.js new file mode 100644 index 000000000000..2673bf6a8f12 --- /dev/null +++ b/packages/astro/test/multiple-jsx-renderers.test.js @@ -0,0 +1,164 @@ +import assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import * as cheerio from 'cheerio'; +import woof from './fixtures/multiple-jsx-renderers/renderers/woof/index.mjs'; +import meow from './fixtures/multiple-jsx-renderers/renderers/meow/index.mjs'; +import { loadFixture } from './test-utils.js'; + +describe('With include option', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/multiple-jsx-renderers/', + integrations: [woof({ include: '**/*.woof.jsx' }), meow({ include: '**/*.meow.jsx' })], + }); + await fixture.build(); + }); + + describe('SSR', () => { + it('WoofCounter rendered by woof', async () => { + const html = await fixture.readFile('/ssr/index.html'); + const $ = cheerio.load(html); + const woofRoot = $('#woof-root'); + assert.equal(woofRoot.find('[data-renderer="woof"]').length, 1); + assert.ok(woofRoot.text().includes('Woof Counter')); + }); + + it('MeowCounter rendered by woof incorrectly (known limitation)', async () => { + const html = await fixture.readFile('/ssr/index.html'); + const $ = cheerio.load(html); + const meowRoot = $('#meow-root'); + + // BUG: SSR-only components don't have metadata.componentUrl, so check() + // can't filter by include pattern. Woof is registered first and claims + // MeowCounter. This should be fixed so meow renders its own component. + assert.equal(meowRoot.find('[data-renderer="woof"]').length, 1); + assert.ok(meowRoot.text().includes('Meow Counter')); + }); + }); + + describe('client:load', () => { + it('WoofCounter rendered by woof', async () => { + const html = await fixture.readFile('/client-load/index.html'); + const $ = cheerio.load(html); + const woofRoot = $('#woof-root'); + const island = woofRoot.find('astro-island'); + assert.equal(woofRoot.find('[data-renderer="woof"]').length, 1); + assert.ok(woofRoot.text().includes('Woof Counter')); + assert.ok(island.attr('component-url')?.includes('WoofCounter.woof')); + assert.ok(island.attr('renderer-url')?.includes('woof-client')); + }); + + it('MeowCounter rendered by meow', async () => { + const html = await fixture.readFile('/client-load/index.html'); + const $ = cheerio.load(html); + const meowRoot = $('#meow-root'); + const island = meowRoot.find('astro-island'); + assert.equal(meowRoot.find('[data-renderer="meow"]').length, 1); + assert.ok(meowRoot.text().includes('Meow Counter')); + assert.ok(island.attr('component-url')?.includes('MeowCounter.meow')); + assert.ok(island.attr('renderer-url')?.includes('meow-client')); + }); + }); + + describe('client:only', () => { + it('WoofCounter uses woof renderer', async () => { + const html = await fixture.readFile('/client-only/index.html'); + const $ = cheerio.load(html); + const island = $('#woof-root').find('astro-island'); + assert.ok(island.attr('component-url')?.includes('WoofCounter.woof')); + assert.ok(island.attr('renderer-url')?.includes('woof-client')); + }); + + it('MeowCounter uses meow renderer', async () => { + const html = await fixture.readFile('/client-only/index.html'); + const $ = cheerio.load(html); + const island = $('#meow-root').find('astro-island'); + assert.ok(island.attr('component-url')?.includes('MeowCounter.meow')); + assert.ok(island.attr('renderer-url')?.includes('meow-client')); + }); + }); +}); + +describe('Without include option', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/multiple-jsx-renderers/', + integrations: [woof(), meow()], + }); + await fixture.build(); + }); + + describe('SSR', () => { + it('WoofCounter rendered by woof', async () => { + const html = await fixture.readFile('/ssr/index.html'); + const $ = cheerio.load(html); + const woofRoot = $('#woof-root'); + assert.equal(woofRoot.find('[data-renderer="woof"]').length, 1); + assert.ok(woofRoot.text().includes('Woof Counter')); + }); + + it('MeowCounter rendered by woof incorrectly', async () => { + const html = await fixture.readFile('/ssr/index.html'); + const $ = cheerio.load(html); + const meowRoot = $('#meow-root'); + + // Without include/exclude options, woof is registered first and claims + // all components. It's the user's responsibility to provide include/exclude + // options when using multiple renderers. + assert.equal(meowRoot.find('[data-renderer="woof"]').length, 1); + assert.ok(meowRoot.text().includes('Meow Counter')); + }); + }); + + describe('client:load', () => { + it('WoofCounter rendered by woof', async () => { + const html = await fixture.readFile('/client-load/index.html'); + const $ = cheerio.load(html); + const woofRoot = $('#woof-root'); + const island = woofRoot.find('astro-island'); + assert.equal(woofRoot.find('[data-renderer="woof"]').length, 1); + assert.ok(woofRoot.text().includes('Woof Counter')); + assert.ok(island.attr('component-url')?.includes('WoofCounter.woof')); + assert.ok(island.attr('renderer-url')?.includes('woof-client')); + }); + + it('MeowCounter rendered by woof incorrectly', async () => { + const html = await fixture.readFile('/client-load/index.html'); + const $ = cheerio.load(html); + const meowRoot = $('#meow-root'); + const island = meowRoot.find('astro-island'); + + // Without include/exclude options, woof is registered first and claims + // all components. It's the user's responsibility to provide include/exclude + // options when using multiple renderers. + assert.equal(meowRoot.find('[data-renderer="woof"]').length, 1); + assert.ok(meowRoot.text().includes('Meow Counter')); + assert.ok(island.attr('component-url')?.includes('MeowCounter.meow')); + assert.ok(island.attr('renderer-url')?.includes('woof-client')); + }); + }); + + describe('client:only', () => { + it('WoofCounter uses woof renderer', async () => { + const html = await fixture.readFile('/client-only/index.html'); + const $ = cheerio.load(html); + const island = $('#woof-root').find('astro-island'); + assert.ok(island.attr('component-url')?.includes('WoofCounter.woof')); + assert.ok(island.attr('renderer-url')?.includes('woof-client')); + }); + + it('MeowCounter uses meow renderer', async () => { + const html = await fixture.readFile('/client-only/index.html'); + const $ = cheerio.load(html); + const island = $('#meow-root').find('astro-island'); + assert.ok(island.attr('component-url')?.includes('MeowCounter.meow')); + assert.ok(island.attr('renderer-url')?.includes('meow-client')); + }); + }); +}); diff --git a/packages/integrations/preact/env.d.ts b/packages/integrations/preact/env.d.ts new file mode 100644 index 000000000000..e9fbf35eb1cd --- /dev/null +++ b/packages/integrations/preact/env.d.ts @@ -0,0 +1,4 @@ +declare module 'astro:preact:opts' { + const options: import('./src/types.js').VirtualModuleOptions; + export default options; +} diff --git a/packages/integrations/preact/package.json b/packages/integrations/preact/package.json index 81099446b625..b2ebe755884b 100644 --- a/packages/integrations/preact/package.json +++ b/packages/integrations/preact/package.json @@ -35,8 +35,10 @@ "dev": "astro-scripts dev \"src/**/*.ts\"" }, "dependencies": { + "@astrojs/internal-helpers": "workspace:*", "@preact/preset-vite": "^2.10.3", "@preact/signals": "^2.8.1", + "devalue": "^5.6.3", "preact-render-to-string": "^6.6.6", "vite": "^7.3.1" }, diff --git a/packages/integrations/preact/src/index.ts b/packages/integrations/preact/src/index.ts index bf12cfe47371..61c5dd2a635a 100644 --- a/packages/integrations/preact/src/index.ts +++ b/packages/integrations/preact/src/index.ts @@ -1,7 +1,9 @@ import { fileURLToPath } from 'node:url'; import { preact, type PreactPluginOptions as VitePreactPluginOptions } from '@preact/preset-vite'; import type { AstroIntegration, AstroRenderer, ViteUserConfig } from 'astro'; +import * as devalue from 'devalue'; import type { EnvironmentOptions, Plugin } from 'vite'; +import type { VirtualModuleOptions } from './types.js'; const babelCwd = new URL('../', import.meta.url); @@ -15,6 +17,36 @@ function getRenderer(development: boolean): AstroRenderer { export const getContainerRenderer = (): AstroRenderer => getRenderer(false); +function optionsPlugin(include: Options['include'], exclude: Options['exclude']): Plugin { + const virtualModule = 'astro:preact:opts'; + const virtualModuleId = '\0' + virtualModule; + return { + name: '@astrojs/preact:opts', + resolveId: { + filter: { + id: new RegExp(`^${virtualModule}$`), + }, + handler() { + return virtualModuleId; + }, + }, + load: { + filter: { + id: new RegExp(`^${virtualModuleId}$`), + }, + handler() { + const opts: VirtualModuleOptions = { + include, + exclude, + }; + return { + code: `export default ${devalue.uneval(opts)}`, + }; + }, + }, + }; +} + export interface Options extends Pick { compat?: boolean; devtools?: boolean; @@ -42,7 +74,11 @@ export default function ({ include, exclude, compat, devtools }: Options = {}): }, }; - viteConfig.plugins = [preactPlugin, configEnvironmentPlugin(compat)]; + viteConfig.plugins = [ + preactPlugin, + optionsPlugin(include, exclude), + configEnvironmentPlugin(compat), + ]; addRenderer(getRenderer(command === 'dev')); updateConfig({ diff --git a/packages/integrations/preact/src/server.ts b/packages/integrations/preact/src/server.ts index 193bb87f6600..35252a528404 100644 --- a/packages/integrations/preact/src/server.ts +++ b/packages/integrations/preact/src/server.ts @@ -1,25 +1,35 @@ import type { AstroComponentMetadata, NamedSSRLoadedRendererValue } from 'astro'; +import opts from 'astro:preact:opts'; import { Component as BaseComponent, h, type VNode } from 'preact'; import { renderToStringAsync } from 'preact-render-to-string'; import { getContext } from './context.js'; import { restoreSignalsOnProps, serializeSignals } from './signals.js'; import StaticHtml from './static-html.js'; import type { AstroPreactAttrs, RendererContext } from './types.js'; +import { createFilter } from '@astrojs/internal-helpers/create-filter'; const slotName = (str: string) => str.trim().replace(/[-_]([a-z])/g, (_, w) => w.toUpperCase()); let originalConsoleError: typeof console.error; let consoleFilterRefs = 0; +const filter = opts?.include || opts?.exclude ? createFilter(opts.include, opts.exclude) : null; + async function check( this: RendererContext, Component: any, props: Record, children: any, + metadata?: AstroComponentMetadata, ) { if (typeof Component !== 'function') return false; if (Component.name === 'QwikComponent') return false; + const componentUrl = metadata?.componentUrl; + if (filter && componentUrl && !filter(componentUrl)) { + return false; + } + if (Component.prototype != null && typeof Component.prototype.render === 'function') { return BaseComponent.isPrototypeOf(Component); } @@ -131,7 +141,11 @@ function finishUsingConsoleFilter() { * Otherwise, simply forwards all arguments to the original function. */ function filteredConsoleError(msg: string, ...rest: any[]) { - if (consoleFilterRefs > 0 && typeof msg === 'string') { + if ( + consoleFilterRefs > 0 && + !process.env.ASTRO_INTERNAL_TEST_DISABLE_CONSOLE_FILTER && + typeof msg === 'string' + ) { // In `check`, we attempt to render JSX components through Preact. // When attempting this on a React component, React may output // the following error, which we can safely filter out: diff --git a/packages/integrations/preact/src/types.ts b/packages/integrations/preact/src/types.ts index e1c56ca30b74..29a2f44c223f 100644 --- a/packages/integrations/preact/src/types.ts +++ b/packages/integrations/preact/src/types.ts @@ -1,4 +1,6 @@ import type { SSRResult } from 'astro'; +import type { FilterPattern } from 'vite'; + export type RendererContext = { result: SSRResult; }; @@ -16,3 +18,8 @@ export type PropNameToSignalMap = Map; - const options: Options; - export = options; + const options: import('./src/types.js').VirtualModuleOptions; + export default options; } diff --git a/packages/integrations/react/package.json b/packages/integrations/react/package.json index 1f2affa85c37..47e32d82d4f5 100644 --- a/packages/integrations/react/package.json +++ b/packages/integrations/react/package.json @@ -39,7 +39,9 @@ "test": "astro-scripts test \"test/**/*.test.js\"" }, "dependencies": { + "@astrojs/internal-helpers": "workspace:*", "@vitejs/plugin-react": "^5.1.4", + "devalue": "^5.6.3", "ultrahtml": "^1.6.0", "vite": "^7.3.1" }, diff --git a/packages/integrations/react/src/index.ts b/packages/integrations/react/src/index.ts index 9e258a153ea3..c5d65476cf65 100644 --- a/packages/integrations/react/src/index.ts +++ b/packages/integrations/react/src/index.ts @@ -8,6 +8,8 @@ import { versionsConfig, } from './version.js'; import type { EnvironmentOptions } from 'vite'; +import type { VirtualModuleOptions } from './types.js'; +import * as devalue from 'devalue'; export type ReactIntegrationOptions = Pick< ViteReactPluginOptions, @@ -31,9 +33,13 @@ function getRenderer(reactConfig: ReactVersionConfig) { } function optionsPlugin({ + include, + exclude, experimentalReactChildren = false, experimentalDisableStreaming = false, }: { + include?: ViteReactPluginOptions['include']; + exclude?: ViteReactPluginOptions['exclude']; experimentalReactChildren: boolean; experimentalDisableStreaming: boolean; }): vite.Plugin { @@ -54,11 +60,14 @@ function optionsPlugin({ id: new RegExp(`^${virtualModuleId}$`), }, handler() { + const opts: VirtualModuleOptions = { + include, + exclude, + experimentalReactChildren, + experimentalDisableStreaming, + }; return { - code: `export default { - experimentalReactChildren: ${JSON.stringify(experimentalReactChildren)}, - experimentalDisableStreaming: ${JSON.stringify(experimentalDisableStreaming)} - }`, + code: `export default ${devalue.uneval(opts)}`, }; }, }, @@ -79,6 +88,8 @@ function getViteConfiguration( plugins: [ react({ include, exclude, babel }), optionsPlugin({ + include, + exclude, experimentalReactChildren: !!experimentalReactChildren, experimentalDisableStreaming: !!experimentalDisableStreaming, }), diff --git a/packages/integrations/react/src/server.ts b/packages/integrations/react/src/server.ts index f7e273e6b80a..4a610d429586 100644 --- a/packages/integrations/react/src/server.ts +++ b/packages/integrations/react/src/server.ts @@ -5,16 +5,20 @@ import ReactDOM from 'react-dom/server'; import { incrementId } from './context.js'; import StaticHtml from './static-html.js'; import type { RendererContext } from './types.js'; +import { createFilter } from '@astrojs/internal-helpers/create-filter'; const slotName = (str: string) => str.trim().replace(/[-_]([a-z])/g, (_, w) => w.toUpperCase()); const reactTypeof = Symbol.for('react.element'); const reactTransitionalTypeof = Symbol.for('react.transitional.element'); +const filter = opts?.include || opts?.exclude ? createFilter(opts.include, opts.exclude) : null; + async function check( this: RendererContext, Component: any, props: Record, children: any, + metadata?: AstroComponentMetadata, ) { // Note: there are packages that do some unholy things to create "components". // Checking the $$typeof property catches most of these patterns. @@ -32,6 +36,10 @@ async function check( return React.Component.isPrototypeOf(Component) || React.PureComponent.isPrototypeOf(Component); } + if (filter && metadata?.componentUrl && !filter(metadata.componentUrl)) { + return false; + } + let isReactComponent = false; function Tester(...args: Array) { try { diff --git a/packages/integrations/react/src/types.ts b/packages/integrations/react/src/types.ts index 5dff5b0b4e21..686f299c3269 100644 --- a/packages/integrations/react/src/types.ts +++ b/packages/integrations/react/src/types.ts @@ -1,4 +1,13 @@ +import type { FilterPattern } from 'vite'; import type { SSRResult } from 'astro'; + export type RendererContext = { result: SSRResult; }; + +export type VirtualModuleOptions = { + include?: FilterPattern; + exclude?: FilterPattern; + experimentalReactChildren?: boolean; + experimentalDisableStreaming?: boolean; +}; diff --git a/packages/internal-helpers/package.json b/packages/internal-helpers/package.json index 42d06af36a2e..89afb6abef92 100644 --- a/packages/internal-helpers/package.json +++ b/packages/internal-helpers/package.json @@ -15,7 +15,8 @@ "./path": "./dist/path.js", "./remote": "./dist/remote.js", "./fs": "./dist/fs.js", - "./cli": "./dist/cli.js" + "./cli": "./dist/cli.js", + "./create-filter": "./dist/create-filter.js" }, "typesVersions": { "*": { @@ -30,6 +31,9 @@ ], "cli": [ "./dist/cli.d.ts" + ], + "create-filter": [ + "./dist/create-filter.d.ts" ] } }, @@ -43,7 +47,11 @@ "dev": "astro-scripts dev \"src/**/*.ts\"", "test": "astro-scripts test \"test/**/*.test.js\"" }, + "dependencies": { + "picomatch": "^4.0.3" + }, "devDependencies": { + "@types/picomatch": "^4.0.2", "astro-scripts": "workspace:*" }, "keywords": [ diff --git a/packages/internal-helpers/src/create-filter.ts b/packages/internal-helpers/src/create-filter.ts new file mode 100644 index 000000000000..255087a00dd5 --- /dev/null +++ b/packages/internal-helpers/src/create-filter.ts @@ -0,0 +1,61 @@ +import picomatch from 'picomatch'; + +import { slash as normalizePath } from './path.js'; + +export type FilterPattern = readonly (string | RegExp)[] | string | RegExp | null; + +function ensureArray(thing: FilterPattern | undefined): readonly (string | RegExp)[] { + if (Array.isArray(thing)) return thing; + if (thing == null) return []; + return [thing] as readonly (string | RegExp)[]; +} + +interface Matcher { + test(what: string): boolean; +} + +function toMatcher(pattern: string | RegExp): RegExp | Matcher { + if (pattern instanceof RegExp) { + return pattern; + } + const normalized = normalizePath(pattern); + const fn = picomatch(normalized, { dot: true }); + return { test: (what: string) => fn(what) }; +} + +// Fork of `createFilter` from `@rollup/pluginutils` without Node.js APIs. +// https://github.com/rollup/plugins/blob/7d16103b995bcf61f5af1040218a50399599c37e/packages/pluginutils/src/createFilter.ts#L26 +export function createFilter( + include?: FilterPattern, + exclude?: FilterPattern, +): (id: string | unknown) => boolean { + const includeMatchers = ensureArray(include).map(toMatcher); + const excludeMatchers = ensureArray(exclude).map(toMatcher); + + if (!includeMatchers.length && !excludeMatchers.length) { + return (id) => typeof id === 'string' && !id.includes('\0'); + } + + return function (id: string | unknown): boolean { + if (typeof id !== 'string') return false; + if (id.includes('\0')) return false; + + const pathId = normalizePath(id); + + for (const matcher of excludeMatchers) { + if (matcher instanceof RegExp) { + matcher.lastIndex = 0; + } + if (matcher.test(pathId)) return false; + } + + for (const matcher of includeMatchers) { + if (matcher instanceof RegExp) { + matcher.lastIndex = 0; + } + if (matcher.test(pathId)) return true; + } + + return !includeMatchers.length; + }; +} diff --git a/packages/internal-helpers/test/create-filter.test.js b/packages/internal-helpers/test/create-filter.test.js new file mode 100644 index 000000000000..f9b53200bfb3 --- /dev/null +++ b/packages/internal-helpers/test/create-filter.test.js @@ -0,0 +1,86 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { createFilter } from '../dist/create-filter.js'; + +describe('createFilter', () => { + describe('no patterns', () => { + it('should return a function', () => { + const filter = createFilter(); + assert.equal(typeof filter, 'function'); + }); + + it('should pass all strings when no patterns given', () => { + const filter = createFilter(); + assert.equal(filter('/src/foo.ts'), true); + assert.equal(filter('bar.js'), true); + }); + + it('should reject non-string values', () => { + const filter = createFilter(); + assert.equal(filter(42), false); + assert.equal(filter(null), false); + assert.equal(filter(undefined), false); + }); + + it('should reject strings with null bytes', () => { + const filter = createFilter(); + assert.equal(filter('file\0.ts'), false); + }); + }); + + describe('include patterns', () => { + it('should filter by glob string', () => { + const filter = createFilter('**/*.tsx'); + assert.equal(filter('/src/components/Button.tsx'), true); + assert.equal(filter('/src/utils/helper.ts'), false); + }); + + it('should filter by glob array', () => { + const filter = createFilter(['**/*.tsx', '**/*.jsx']); + assert.equal(filter('/src/Button.tsx'), true); + assert.equal(filter('/src/Button.jsx'), true); + assert.equal(filter('/src/helper.ts'), false); + }); + + it('should filter by RegExp', () => { + const filter = createFilter(/\.tsx$/); + assert.equal(filter('/src/Button.tsx'), true); + assert.equal(filter('/src/helper.ts'), false); + }); + + it('should return false for non-matching paths when include is specified', () => { + const filter = createFilter(['**/*.tsx']); + assert.equal(filter('/src/app.ts'), false); + }); + }); + + describe('exclude patterns', () => { + it('should exclude matching paths', () => { + const filter = createFilter(null, ['**/node_modules/**']); + assert.equal(filter('/src/app.ts'), true); + assert.equal(filter('/node_modules/pkg/index.js'), false); + }); + + it('should prioritize exclude over include', () => { + const filter = createFilter(['**/*.ts'], ['**/test/**']); + assert.equal(filter('/src/app.ts'), true); + assert.equal(filter('/test/app.ts'), false); + }); + }); + + describe('path normalization', () => { + it('should normalize backslashes', () => { + const filter = createFilter(['**/*.ts']); + assert.equal(filter('src\\components\\App.ts'), true); + }); + }); + + describe('RegExp lastIndex handling', () => { + it('should reset lastIndex on global RegExp', () => { + const filter = createFilter(/\.tsx$/g); + assert.equal(filter('/src/Button.tsx'), true); + assert.equal(filter('/src/Other.tsx'), true); + assert.equal(filter('/src/Another.tsx'), true); + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cdd9bf9da87f..c63ea01bbc24 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3717,6 +3717,12 @@ importers: specifier: workspace:* version: link:../../.. + packages/astro/test/fixtures/multiple-jsx-renderers: + dependencies: + astro: + specifier: workspace:* + version: link:../../.. + packages/astro/test/fixtures/multiple-renderers: dependencies: '@test/astro-renderer-one': @@ -6055,12 +6061,18 @@ importers: packages/integrations/preact: dependencies: + '@astrojs/internal-helpers': + specifier: workspace:* + version: link:../../internal-helpers '@preact/preset-vite': specifier: ^2.10.3 version: 2.10.3(@babel/core@7.29.0)(preact@10.28.4)(rollup@4.58.0)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.3)(tsx@4.21.0)(yaml@2.8.2)) '@preact/signals': specifier: ^2.8.1 version: 2.8.1(preact@10.28.4) + devalue: + specifier: ^5.6.3 + version: 5.6.3 preact-render-to-string: specifier: ^6.6.6 version: 6.6.6(preact@10.28.4) @@ -6080,9 +6092,15 @@ importers: packages/integrations/react: dependencies: + '@astrojs/internal-helpers': + specifier: workspace:* + version: link:../../internal-helpers '@vitejs/plugin-react': specifier: ^5.1.4 version: 5.1.4(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.3)(tsx@4.21.0)(yaml@2.8.2)) + devalue: + specifier: ^5.6.3 + version: 5.6.3 ultrahtml: specifier: ^1.6.0 version: 1.6.0 @@ -6631,7 +6649,14 @@ importers: version: 3.5.29(typescript@5.9.3) packages/internal-helpers: + dependencies: + picomatch: + specifier: ^4.0.3 + version: 4.0.3 devDependencies: + '@types/picomatch': + specifier: ^4.0.2 + version: 4.0.2 astro-scripts: specifier: workspace:* version: link:../../scripts From 02e24d952de29c1c633744e7408215bedeb4d436 Mon Sep 17 00:00:00 2001 From: Matthew Phillips Date: Thu, 5 Mar 2026 09:06:36 -0500 Subject: [PATCH 6/6] Harden origin check port handling for consistency (#15777) Co-authored-by: bugbot Co-authored-by: ematipico --- .changeset/harden-origin-port-consistency.md | 6 + packages/astro/src/core/app/node.ts | 12 +- .../astro/src/core/app/validate-headers.ts | 7 +- packages/astro/test/units/app/node.test.js | 127 ++++++++++++++++++ packages/integrations/node/src/serve-app.ts | 1 + .../integrations/node/src/serve-static.ts | 1 + packages/integrations/node/src/standalone.ts | 6 +- 7 files changed, 154 insertions(+), 6 deletions(-) create mode 100644 .changeset/harden-origin-port-consistency.md diff --git a/.changeset/harden-origin-port-consistency.md b/.changeset/harden-origin-port-consistency.md new file mode 100644 index 000000000000..4d4f6d9d4872 --- /dev/null +++ b/.changeset/harden-origin-port-consistency.md @@ -0,0 +1,6 @@ +--- +'astro': patch +'@astrojs/node': patch +--- + +Fixes CSRF origin check mismatch by passing the actual server listening port to `createRequest`, ensuring the constructed URL origin includes the correct port (e.g., `http://localhost:4321` instead of `http://localhost`). Also restricts `X-Forwarded-Proto` to only be trusted when `allowedDomains` is configured. diff --git a/packages/astro/src/core/app/node.ts b/packages/astro/src/core/app/node.ts index b58db09ec2eb..2127327d5cf1 100644 --- a/packages/astro/src/core/app/node.ts +++ b/packages/astro/src/core/app/node.ts @@ -39,7 +39,8 @@ export function createRequest( { skipBody = false, allowedDomains = [], - }: { skipBody?: boolean; allowedDomains?: Partial[] } = {}, + port: serverPort, + }: { skipBody?: boolean; allowedDomains?: Partial[]; port?: number } = {}, ): Request { const controller = new AbortController(); @@ -76,7 +77,14 @@ export function createRequest( allowedDomains, ); const hostname = validated.host ?? validatedHostname ?? 'localhost'; - const port = validated.port; + // Use the validated forwarded port if available. When falling back to 'localhost' + // (no validated host), use the actual server listening port so that the constructed + // URL origin includes it (e.g., http://localhost:4321 instead of http://localhost). + // This ensures the CSRF origin comparison uses the correct port without trusting + // any value from the request headers. + const port = + validated.port ?? + (!validated.host && !validatedHostname && serverPort ? String(serverPort) : undefined); let url: URL; try { diff --git a/packages/astro/src/core/app/validate-headers.ts b/packages/astro/src/core/app/validate-headers.ts index c5b452a12e09..f6e495f15a4d 100644 --- a/packages/astro/src/core/app/validate-headers.ts +++ b/packages/astro/src/core/app/validate-headers.ts @@ -106,10 +106,11 @@ export function validateForwardedHeaders( // allowedDomains exist but no protocol patterns, allow http/https result.protocol = forwardedProtocol; } - } else if (/^https?$/.test(forwardedProtocol)) { - // No allowedDomains, only allow http/https - result.protocol = forwardedProtocol; } + // When no allowedDomains is configured, do not trust X-Forwarded-Proto. + // Without allowedDomains there is no proxy configuration to validate against, + // so accepting a forwarded protocol could let an attacker change the origin + // used for comparisons (e.g., switching http to https). } // Validate port first diff --git a/packages/astro/test/units/app/node.test.js b/packages/astro/test/units/app/node.test.js index a7a8d5c9909d..1f3305e4dc1f 100644 --- a/packages/astro/test/units/app/node.test.js +++ b/packages/astro/test/units/app/node.test.js @@ -448,6 +448,96 @@ describe('node', () => { assert.equal(result.url, 'https://localhost/'); }); + it('includes server port in localhost fallback when port option is provided', () => { + const result = createRequest( + { + ...mockNodeRequest, + socket: { encrypted: false, remoteAddress: '2.2.2.2' }, + headers: { + host: 'anything.com', + }, + }, + { port: 4321 }, + ); + // The server port should be included so that + // url.origin is http://localhost:4321, not http://localhost + const url = new URL(result.url); + assert.equal(url.hostname, 'localhost'); + assert.equal(url.port, '4321'); + assert.equal(url.origin, 'http://localhost:4321'); + }); + + it('includes server port in localhost fallback when allowedDomains is empty', () => { + const result = createRequest( + { + ...mockNodeRequest, + socket: { encrypted: false, remoteAddress: '2.2.2.2' }, + headers: { + host: 'anything.com', + }, + }, + { allowedDomains: [], port: 4321 }, + ); + assert.equal(new URL(result.url).origin, 'http://localhost:4321'); + }); + + it('does not use server port when host is validated via allowedDomains', () => { + const result = createRequest( + { + ...mockNodeRequest, + socket: { encrypted: false, remoteAddress: '2.2.2.2' }, + headers: { + host: 'example.com', + }, + }, + { allowedDomains: [{ hostname: 'example.com' }], port: 4321 }, + ); + // When the host is validated, the server port should NOT be appended + assert.equal(result.url, 'http://example.com/'); + }); + + it('omits default port 80 for http when server port is 80', () => { + const result = createRequest( + { + ...mockNodeRequest, + socket: { encrypted: false, remoteAddress: '2.2.2.2' }, + headers: { + host: 'anything.com', + }, + }, + { port: 80 }, + ); + // Port 80 is the default for http, so URL normalizes it away + assert.equal(new URL(result.url).origin, 'http://localhost'); + }); + + it('omits default port 443 for https when server port is 443', () => { + const result = createRequest( + { + ...mockNodeRequest, + socket: { encrypted: true, remoteAddress: '2.2.2.2' }, + headers: { + host: 'anything.com', + }, + }, + { port: 443 }, + ); + // Port 443 is the default for https, so URL normalizes it away + assert.equal(new URL(result.url).origin, 'https://localhost'); + }); + + it('does not include port when no port option is provided', () => { + const result = createRequest({ + ...mockNodeRequest, + socket: { encrypted: false, remoteAddress: '2.2.2.2' }, + headers: { + host: 'anything.com', + }, + }); + // Without port option, falls back to localhost with no port + assert.equal(new URL(result.url).origin, 'http://localhost'); + }); + it('prefers x-forwarded-host over Host header when both match allowedDomains', () => { const result = createRequest( { @@ -563,6 +653,43 @@ describe('node', () => { assert.equal(result.url, 'https://example.com/'); }); + it('rejects x-forwarded-proto when no allowedDomains is configured', () => { + const result = createRequest( + { + ...mockNodeRequest, + socket: { encrypted: false, remoteAddress: '2.2.2.2' }, + headers: { + host: 'localhost:4321', + 'x-forwarded-proto': 'https', + }, + }, + { port: 4321 }, + ); + // Without allowedDomains, x-forwarded-proto should NOT be trusted + // Falls back to socket.encrypted (false → http) + const url = new URL(result.url); + assert.equal(url.protocol, 'http:'); + assert.equal(url.origin, 'http://localhost:4321'); + }); + + it('rejects x-forwarded-proto when allowedDomains is empty', () => { + const result = createRequest( + { + ...mockNodeRequest, + socket: { encrypted: false, remoteAddress: '2.2.2.2' }, + headers: { + host: 'localhost:4321', + 'x-forwarded-proto': 'https', + }, + }, + { allowedDomains: [], port: 4321 }, + ); + // Empty allowedDomains means x-forwarded-proto is not trusted + const url = new URL(result.url); + assert.equal(url.protocol, 'http:'); + assert.equal(url.origin, 'http://localhost:4321'); + }); + it('rejects empty x-forwarded-proto and falls back to encrypted property', () => { const result = createRequest( { diff --git a/packages/integrations/node/src/serve-app.ts b/packages/integrations/node/src/serve-app.ts index 44ea264df633..3f646e9d7845 100644 --- a/packages/integrations/node/src/serve-app.ts +++ b/packages/integrations/node/src/serve-app.ts @@ -80,6 +80,7 @@ export function createAppHandler(app: BaseApp, options: Options): RequestHandler try { request = createRequest(req, { allowedDomains: app.getAllowedDomains?.() ?? [], + port: options.port, }); } catch (err) { logger.error(`Could not render ${req.url}`); diff --git a/packages/integrations/node/src/serve-static.ts b/packages/integrations/node/src/serve-static.ts index 34843e93e2ce..33c5ef9cc258 100644 --- a/packages/integrations/node/src/serve-static.ts +++ b/packages/integrations/node/src/serve-static.ts @@ -66,6 +66,7 @@ export function createStaticHandler( if (headersMap && headersMap.length > 0) { const request = createRequest(req, { allowedDomains: app.getAllowedDomains?.() ?? [], + port: options.port, }); const routeData = app.match(request, true); if (routeData && routeData.prerender) { diff --git a/packages/integrations/node/src/standalone.ts b/packages/integrations/node/src/standalone.ts index 154bb16c74c3..35f366662995 100644 --- a/packages/integrations/node/src/standalone.ts +++ b/packages/integrations/node/src/standalone.ts @@ -24,7 +24,11 @@ export default function standalone( ) { const port = process.env.PORT ? Number(process.env.PORT) : (options.port ?? 8080); const host = process.env.HOST ?? hostOptions(options.host); - const handler = createStandaloneHandler(app, options, headersMap); + // Ensure the resolved port (which may come from process.env.PORT) is used + // by createRequest for origin construction, not just the build-time config port. + // We spread a new object because `options` may be a frozen module namespace. + const resolvedOptions = { ...options, port }; + const handler = createStandaloneHandler(app, resolvedOptions, headersMap); const server = createServer(handler, host, port); server.server.listen(port, host); if (process.env.ASTRO_NODE_LOGGING !== 'disabled') {