diff --git a/.changeset/csp-shiki-warning.md b/.changeset/csp-shiki-warning.md new file mode 100644 index 000000000000..d30942d12fba --- /dev/null +++ b/.changeset/csp-shiki-warning.md @@ -0,0 +1,7 @@ +--- +'astro': patch +--- + +Reverts the support of Shiki with CSP. Unfortunately, after exhaustive tests, the highlighter can't be supported to cover all cases. + +Adds a warning when both Content Security Policy (CSP) and Shiki syntax highlighting are enabled, as they are incompatible due to Shiki's use of inline styles diff --git a/.changeset/fix-ssr-prerendered-image-deletion.md b/.changeset/fix-ssr-prerendered-image-deletion.md new file mode 100644 index 000000000000..01041c39b228 --- /dev/null +++ b/.changeset/fix-ssr-prerendered-image-deletion.md @@ -0,0 +1,5 @@ +--- +"astro": patch +--- + +Fixes an issue where original (unoptimized) images from prerendered pages could be kept in the build output during SSR builds. diff --git a/.changeset/happy-frogs-glow.md b/.changeset/happy-frogs-glow.md deleted file mode 100644 index 763ca0d46395..000000000000 --- a/.changeset/happy-frogs-glow.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@astrojs/markdown-remark': major -'@astrojs/mdx': major -'astro': major ---- - -Changes how styles applied to code blocks are emitted to support CSP - ([v6 upgrade guidance](https://v6.docs.astro.build/en/guides/upgrade-to/v6/#changed-how-shiki-code-block-styles-are-emitted)) diff --git a/.changeset/hot-eyes-sink.md b/.changeset/hot-eyes-sink.md new file mode 100644 index 000000000000..8787dcbdd372 --- /dev/null +++ b/.changeset/hot-eyes-sink.md @@ -0,0 +1,63 @@ +--- +'astro': minor +--- + +Adds a new experimental flag `queuedRendering` to enable a queue-based rendering engine + +The new engine is based on a two-pass process, where the first pass +traverses the tree of components, emits an ordered queue, and then the queue is rendered. + +The new engine does not use recursion, and comes with two customizable options. + +Early benchmarks showed significant speed improvements and memory efficiency in big projects. + +#### Queue-rendered based + +The new engine can be enabled in your Astro config with `experimental.queuedRendering.enabled` set to `true`, and can be further customized with additional sub-features. + +```js +// astro.config.mjs +export default defineConfig({ + experimental: { + queuedRendering: { + enabled: true + } + } +}) +``` + +#### Pooling + +With the new engine enabled, you now have the option to have a pool of nodes that can be saved and reused across page rendering. Node pooling has no effect when rendering pages on demand (SSR) because these rendering requests don't share memory. However, it can be very useful for performance when building static pages. + +```js +// astro.config.mjs +export default defineConfig({ + experimental: { + queuedRendering: { + enabled: true, + poolSize: 2000 // store up to 2k nodes to be reused across renderers + } + } +}); +``` + +#### Content caching + +The new engine additionally unlocks a new `contentCache` option. This allows you to cache values of nodes during the rendering phase. This is currently a boolean feature with no further customization (e.g. size of cache) that uses sensible defaults for most large content collections: + +When disabled, the pool engine won't cache strings, but only types. + +```js +// astro.config.mjs +export default defineConfig({ + experimental: { + queuedRendering: { + enabled: true, + contentCache: true // enable re-use of node values + } + } +}); +``` + +For more information on enabling and using this feature in your project, see the [experimental queued rendering docs](https://v6.docs.astro.build/en/reference/experimental-flags/queued-rendering/) for more details. diff --git a/.changeset/pre.json b/.changeset/pre.json index caeeeeb8a6a0..ea4ffeeee7a9 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -53,6 +53,7 @@ "better-redirect-error", "big-cups-drive", "big-forks-lead", + "big-jobs-make", "big-laws-dig", "bold-ghosts-sink", "bright-parrots-hear", @@ -63,19 +64,26 @@ "busy-actors-follow", "busy-humans-smoke", "busy-olives-chew", + "calm-bats-create", "calm-birds-fly", + "chubby-tips-change", "clean-planets-flow", "clear-areas-cry", "clear-route-cache-content", "clever-clubs-listen", "cloudflare-dev-styles", "cloudflare-entrypoint-breaking", + "cloudflare-image-component", + "cloudflare-image-dev-fix", + "cloudflare-image-service-object", "cloudflare-integration-wrangler", "cloudflare-workerd-prerender", "common-knives-rescue", "common-signs-punch", + "csp-shiki-warning", "cuddly-worlds-beam", "custom-prerenderer-api", + "cute-suns-check", "cyan-crews-cross", "dance-ornate-keen", "deep-states-talk", @@ -95,20 +103,25 @@ "famous-trees-tan", "fancy-bananas-sing", "fast-bushes-fall", + "fast-wolves-render", "fifty-dingos-study", "fix-content-hmr", "fix-create-astro-registry-hang", + "fix-font-head-swap", "fix-forwarded-proto-allowed-domains", + "fix-large-static-routes-stack-overflow", "fix-markdoc-table-attributes", "fix-mdx-slot-hydration", "fix-preact-cloudflare-hooks", "fix-rewrite-non-ascii-paths", "fix-serve-files-outside-srcdir", + "fix-ssr-prerendered-image-deletion", "fix-store-race-condition", "fix-vite-runner-closed", "flat-lions-care", "flat-symbols-arrive", "floppy-bottles-notice", + "floppy-cases-hug", "fluffy-pets-greet", "fluffy-shrimps-drop", "four-insects-tan", @@ -119,23 +132,28 @@ "full-poems-divide", "funny-planes-lick", "giant-areas-press", + "giant-bananas-sit", "good-camels-pull", "good-clubs-cover", "great-nails-brake", "green-garlics-heal", + "green-plants-act", + "green-zebras-lick", "grumpy-tables-serve", - "happy-frogs-glow", "heavy-beers-unite", "heavy-cats-own", "heavy-parts-throw", "helpful-runtime-errors", "hojze-jglnm-ggfbv", "honest-deer-add", + "hot-eyes-sink", "hungry-jars-pump", "icy-pigs-smile", "internal-helpers-normalize-pathname", "itchy-ghosts-flow", + "jolly-dots-shop", "khaki-bushes-stop", + "khaki-toys-think", "kind-emus-relate", "kind-pears-behave", "large-ears-ask", @@ -164,6 +182,7 @@ "open-days-watch", "open-monkeys-boil", "optional-wrangler-config", + "orange-boats-refuse", "polite-terms-shop", "pretty-forks-smash", "proud-pans-change", @@ -182,8 +201,10 @@ "rich-horses-begin", "ripe-nights-feel", "route-data-breaking", + "rust-compiler-experimental", "sad-lines-hear", "sad-teams-end", + "server-island-slot-scripts", "short-pears-hammer", "shy-cats-grin", "silly-eels-remain", @@ -213,9 +234,11 @@ "swift-planets-swim", "tall-needles-cross", "tall-worms-live", + "tame-lemons-probe", "tangy-days-wink", "tangy-tables-jog", "tangy-years-grin", + "ten-numbers-rush", "tender-bats-tan", "tender-moose-help", "thin-hands-find", @@ -225,6 +248,7 @@ "tricky-donkeys-camp", "true-bats-punch", "true-dingos-attack", + "twenty-signs-brush", "upset-dodos-rhyme", "vite-environments-breaking", "vite-plugin-react-v5", diff --git a/.changeset/server-island-slot-scripts.md b/.changeset/server-island-slot-scripts.md new file mode 100644 index 000000000000..3066706588e2 --- /dev/null +++ b/.changeset/server-island-slot-scripts.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fixes a case where ` +
+

This page tests script and style tags

+
+ + diff --git a/packages/astro/test/fixtures/queue-rendering/astro.config.mjs b/packages/astro/test/fixtures/queue-rendering/astro.config.mjs new file mode 100644 index 000000000000..f86e8a140cae --- /dev/null +++ b/packages/astro/test/fixtures/queue-rendering/astro.config.mjs @@ -0,0 +1,11 @@ +import { defineConfig } from 'astro/config'; +import react from '@astrojs/react'; + +export default defineConfig({ + integrations: [react()], + experimental: { + queuedRendering: { + enabled: true, + }, + }, +}); diff --git a/packages/astro/test/fixtures/queue-rendering/package.json b/packages/astro/test/fixtures/queue-rendering/package.json new file mode 100644 index 000000000000..557362d1c348 --- /dev/null +++ b/packages/astro/test/fixtures/queue-rendering/package.json @@ -0,0 +1,11 @@ +{ + "name": "@test/queue-rendering", + "version": "0.0.0", + "private": true, + "dependencies": { + "astro": "workspace:*", + "@astrojs/react": "workspace:*", + "react": "^18.3.1", + "react-dom": "^18.3.1" + } +} diff --git a/packages/astro/test/fixtures/queue-rendering/src/components/Counter.jsx b/packages/astro/test/fixtures/queue-rendering/src/components/Counter.jsx new file mode 100644 index 000000000000..27e86047c16c --- /dev/null +++ b/packages/astro/test/fixtures/queue-rendering/src/components/Counter.jsx @@ -0,0 +1,12 @@ +import { useState } from 'react'; + +export default function Counter({ initialCount = 0 }) { + const [count, setCount] = useState(initialCount); + + return ( +
+

Count: {count}

+ +
+ ); +} diff --git a/packages/astro/test/fixtures/queue-rendering/src/components/Nested.astro b/packages/astro/test/fixtures/queue-rendering/src/components/Nested.astro new file mode 100644 index 000000000000..2ef06af9e559 --- /dev/null +++ b/packages/astro/test/fixtures/queue-rendering/src/components/Nested.astro @@ -0,0 +1,6 @@ +--- +const { level = 0 } = Astro.props; +--- +
+ Level {level} +
diff --git a/packages/astro/test/fixtures/queue-rendering/src/components/Static.jsx b/packages/astro/test/fixtures/queue-rendering/src/components/Static.jsx new file mode 100644 index 000000000000..c4e5c176c6ec --- /dev/null +++ b/packages/astro/test/fixtures/queue-rendering/src/components/Static.jsx @@ -0,0 +1,7 @@ +export default function Static({ message }) { + return ( +
+

Message: {message}

+
+ ); +} diff --git a/packages/astro/test/fixtures/queue-rendering/src/components/WithHead.astro b/packages/astro/test/fixtures/queue-rendering/src/components/WithHead.astro new file mode 100644 index 000000000000..2309abcb3d7b --- /dev/null +++ b/packages/astro/test/fixtures/queue-rendering/src/components/WithHead.astro @@ -0,0 +1,17 @@ +--- +const { title } = Astro.props; +--- +
+

{title}

+

This component adds content to the head

+
+ + + + diff --git a/packages/astro/test/fixtures/queue-rendering/src/components/WithSlot.astro b/packages/astro/test/fixtures/queue-rendering/src/components/WithSlot.astro new file mode 100644 index 000000000000..9a22e7c043db --- /dev/null +++ b/packages/astro/test/fixtures/queue-rendering/src/components/WithSlot.astro @@ -0,0 +1,9 @@ +--- +const { title } = Astro.props; +--- +
+

{title}

+
+ +
+
diff --git a/packages/astro/test/fixtures/queue-rendering/src/pages/client-components.astro b/packages/astro/test/fixtures/queue-rendering/src/pages/client-components.astro new file mode 100644 index 000000000000..5ca7ba363c46 --- /dev/null +++ b/packages/astro/test/fixtures/queue-rendering/src/pages/client-components.astro @@ -0,0 +1,42 @@ +--- +import Counter from '../components/Counter.jsx'; +import Static from '../components/Static.jsx'; +--- + + + Client Components Test + + +

Client Components Test

+ +
+

client:load

+ +
+ +
+

client:idle

+ +
+ +
+

client:visible

+ +
+ +
+

client:media

+ +
+ +
+

client:only

+ +
+ +
+

No client directive (SSR only)

+ +
+ + diff --git a/packages/astro/test/fixtures/queue-rendering/src/pages/directives.astro b/packages/astro/test/fixtures/queue-rendering/src/pages/directives.astro new file mode 100644 index 000000000000..b68aaa292c29 --- /dev/null +++ b/packages/astro/test/fixtures/queue-rendering/src/pages/directives.astro @@ -0,0 +1,33 @@ +--- +const htmlContent = 'Bold text from set:html'; +const textContent = 'This should be escaped'; +const inlineStyle = { color: 'red', fontSize: '20px' }; +--- + + + Astro Directives Test + + +

Directives Test

+ +
+

set:html

+
+
+ +
+

set:text

+
+
+ +
+

class:list

+
Class List Test
+
+ +
+

Inline Style Object

+
Styled Text
+
+ + diff --git a/packages/astro/test/fixtures/queue-rendering/src/pages/head-content.astro b/packages/astro/test/fixtures/queue-rendering/src/pages/head-content.astro new file mode 100644 index 000000000000..ec7ae11a8dce --- /dev/null +++ b/packages/astro/test/fixtures/queue-rendering/src/pages/head-content.astro @@ -0,0 +1,31 @@ +--- +import WithHead from '../components/WithHead.astro'; +--- + + + Head Content Test + + + +

Head Content Test

+ +
+ +

Inline styles test

+
+ +
+ +
+ +
+ +
+ + diff --git a/packages/astro/test/fixtures/queue-rendering/src/pages/index.astro b/packages/astro/test/fixtures/queue-rendering/src/pages/index.astro new file mode 100644 index 000000000000..8200422133ab --- /dev/null +++ b/packages/astro/test/fixtures/queue-rendering/src/pages/index.astro @@ -0,0 +1,39 @@ +--- +import Nested from '../components/Nested.astro'; +import WithSlot from '../components/WithSlot.astro'; + +const items = ['First', 'Second', 'Third']; +--- + + + Queue Rendering Test + + +

Queue Rendering Test

+ +
+

Simple text rendering

+

Number: {42}

+

Boolean: {true}

+
+ +
+ +
+ +
+ + + +
+ +
+ +

Slot content here

+

Multiple paragraphs

+
+
+ + diff --git a/packages/astro/test/fixtures/route-manifest/basic/about.astro b/packages/astro/test/fixtures/route-manifest/basic/about.astro deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/basic/blog/[slug].astro b/packages/astro/test/fixtures/route-manifest/basic/blog/[slug].astro deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/basic/blog/index.astro b/packages/astro/test/fixtures/route-manifest/basic/blog/index.astro deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/basic/index.astro b/packages/astro/test/fixtures/route-manifest/basic/index.astro deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/encoding/#.astro b/packages/astro/test/fixtures/route-manifest/encoding/#.astro deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/hidden-dot/.unknown/foo.txt.js b/packages/astro/test/fixtures/route-manifest/hidden-dot/.unknown/foo.txt.js deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/hidden-dot/.well-known/dnt-policy.astro b/packages/astro/test/fixtures/route-manifest/hidden-dot/.well-known/dnt-policy.astro deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/hidden-underscore/_foo.js b/packages/astro/test/fixtures/route-manifest/hidden-underscore/_foo.js deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/hidden-underscore/a/_b/c/d.js b/packages/astro/test/fixtures/route-manifest/hidden-underscore/a/_b/c/d.js deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/hidden-underscore/e/f/g/h.astro b/packages/astro/test/fixtures/route-manifest/hidden-underscore/e/f/g/h.astro deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/hidden-underscore/i/_j.js b/packages/astro/test/fixtures/route-manifest/hidden-underscore/i/_j.js deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/hidden-underscore/index.astro b/packages/astro/test/fixtures/route-manifest/hidden-underscore/index.astro deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/invalid-extension/about.astro b/packages/astro/test/fixtures/route-manifest/invalid-extension/about.astro deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/invalid-extension/image.svg b/packages/astro/test/fixtures/route-manifest/invalid-extension/image.svg deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/invalid-extension/index.astro b/packages/astro/test/fixtures/route-manifest/invalid-extension/index.astro deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/invalid-extension/styles.css b/packages/astro/test/fixtures/route-manifest/invalid-extension/styles.css deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/invalid-params/[foo][bar].astro b/packages/astro/test/fixtures/route-manifest/invalid-params/[foo][bar].astro deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/invalid-rest/foo-[...rest]-bar.astro b/packages/astro/test/fixtures/route-manifest/invalid-rest/foo-[...rest]-bar.astro deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/lockfiles/foo.astro b/packages/astro/test/fixtures/route-manifest/lockfiles/foo.astro deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/lockfiles/foo.astro_tmp b/packages/astro/test/fixtures/route-manifest/lockfiles/foo.astro_tmp deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/multiple-slugs/[file].[ext].astro b/packages/astro/test/fixtures/route-manifest/multiple-slugs/[file].[ext].astro deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/package.json b/packages/astro/test/fixtures/route-manifest/package.json deleted file mode 100644 index 86465c567222..000000000000 --- a/packages/astro/test/fixtures/route-manifest/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "@test/route-manifest", - "version": "0.0.0", - "private": true, - "dependencies": { - "astro": "workspace:*" - } -} diff --git a/packages/astro/test/fixtures/route-manifest/sorting/[...rest]/abc.astro b/packages/astro/test/fixtures/route-manifest/sorting/[...rest]/abc.astro deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/sorting/[...rest]/deep/[...deep_rest]/index.astro b/packages/astro/test/fixtures/route-manifest/sorting/[...rest]/deep/[...deep_rest]/index.astro deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/sorting/[...rest]/deep/[...deep_rest]/xyz.astro b/packages/astro/test/fixtures/route-manifest/sorting/[...rest]/deep/[...deep_rest]/xyz.astro deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/sorting/[...rest]/deep/index.astro b/packages/astro/test/fixtures/route-manifest/sorting/[...rest]/deep/index.astro deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/sorting/[...rest]/index.astro b/packages/astro/test/fixtures/route-manifest/sorting/[...rest]/index.astro deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/sorting/[wildcard].astro b/packages/astro/test/fixtures/route-manifest/sorting/[wildcard].astro deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/sorting/_layout.astro b/packages/astro/test/fixtures/route-manifest/sorting/_layout.astro deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/sorting/about.astro b/packages/astro/test/fixtures/route-manifest/sorting/about.astro deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/sorting/index.astro b/packages/astro/test/fixtures/route-manifest/sorting/index.astro deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/sorting/post/[id].astro b/packages/astro/test/fixtures/route-manifest/sorting/post/[id].astro deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/sorting/post/_default.astro b/packages/astro/test/fixtures/route-manifest/sorting/post/_default.astro deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/sorting/post/bar.astro b/packages/astro/test/fixtures/route-manifest/sorting/post/bar.astro deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/sorting/post/f[xx].astro b/packages/astro/test/fixtures/route-manifest/sorting/post/f[xx].astro deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/sorting/post/f[yy].astro b/packages/astro/test/fixtures/route-manifest/sorting/post/f[yy].astro deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/sorting/post/foo.astro b/packages/astro/test/fixtures/route-manifest/sorting/post/foo.astro deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/sorting/post/index.astro b/packages/astro/test/fixtures/route-manifest/sorting/post/index.astro deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/routing-priority/astro.config.mjs b/packages/astro/test/fixtures/routing-priority/astro.config.mjs deleted file mode 100644 index a5103a7f405f..000000000000 --- a/packages/astro/test/fixtures/routing-priority/astro.config.mjs +++ /dev/null @@ -1,7 +0,0 @@ -import { defineConfig } from 'astro/config'; -import integration from './integration.mjs'; - -// https://astro.build/config -export default defineConfig({ - integrations: [integration()] -}); diff --git a/packages/astro/test/fixtures/routing-priority/integration.mjs b/packages/astro/test/fixtures/routing-priority/integration.mjs deleted file mode 100644 index 57ecc307336c..000000000000 --- a/packages/astro/test/fixtures/routing-priority/integration.mjs +++ /dev/null @@ -1,21 +0,0 @@ -export default function() { - return { - name: '@astrojs/test-integration', - hooks: { - 'astro:config:setup': ({ injectRoute }) => { - injectRoute({ - pattern: '/injected', - entrypoint: './src/to-inject.astro' - }); - injectRoute({ - pattern: '/_injected', - entrypoint: './src/_to-inject.astro' - }); - injectRoute({ - pattern: '/[id]', - entrypoint: './src/[id].astro' - }); - } - } - } -} diff --git a/packages/astro/test/fixtures/routing-priority/package.json b/packages/astro/test/fixtures/routing-priority/package.json deleted file mode 100644 index 01c23a914fa9..000000000000 --- a/packages/astro/test/fixtures/routing-priority/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "@test/routing-priority", - "version": "0.0.0", - "private": true, - "dependencies": { - "astro": "workspace:*" - } -} diff --git a/packages/astro/test/fixtures/routing-priority/src/[id].astro b/packages/astro/test/fixtures/routing-priority/src/[id].astro deleted file mode 100644 index af235c9a851a..000000000000 --- a/packages/astro/test/fixtures/routing-priority/src/[id].astro +++ /dev/null @@ -1,21 +0,0 @@ ---- -export async function getStaticPaths() { - return [ - { params: { id: 'injected-1' } }, - { params: { id: 'injected-2' } } - ]; -} - -const { id } = Astro.params; ---- - - - - - Routing - - -

[id].astro

-

{id}

- - diff --git a/packages/astro/test/fixtures/routing-priority/src/_to-inject.astro b/packages/astro/test/fixtures/routing-priority/src/_to-inject.astro deleted file mode 100644 index 4c5b6428f48d..000000000000 --- a/packages/astro/test/fixtures/routing-priority/src/_to-inject.astro +++ /dev/null @@ -1,12 +0,0 @@ ---- ---- - - - - - Routing - - -

to-inject.astro

- - diff --git a/packages/astro/test/fixtures/routing-priority/src/pages/[lang]/[...catchall].astro b/packages/astro/test/fixtures/routing-priority/src/pages/[lang]/[...catchall].astro deleted file mode 100644 index f212b6e53eaa..000000000000 --- a/packages/astro/test/fixtures/routing-priority/src/pages/[lang]/[...catchall].astro +++ /dev/null @@ -1,23 +0,0 @@ ---- -export async function getStaticPaths() { - return [ - { params: { lang: 'de', catchall: '1/2' } }, - { params: { lang: 'en', catchall: '1/2' } } - ] - } ---- - - - - - - - Routing - - - -

[lang]/[...catchall].astro

-

{Astro.params.lang} | {Astro.params.catchall}

- - - diff --git a/packages/astro/test/fixtures/routing-priority/src/pages/[lang]/index.astro b/packages/astro/test/fixtures/routing-priority/src/pages/[lang]/index.astro deleted file mode 100644 index 4af288f0bfc4..000000000000 --- a/packages/astro/test/fixtures/routing-priority/src/pages/[lang]/index.astro +++ /dev/null @@ -1,23 +0,0 @@ ---- -export async function getStaticPaths() { - return [ - { params: { lang: 'de' } }, // always shadowed by /de/index.astro - { params: { lang: 'en' } } - ]; - } ---- - - - - - - - Routing - - - -

[lang]/index.astro

-

{Astro.params.lang}

- - - diff --git a/packages/astro/test/fixtures/routing-priority/src/pages/[page].astro b/packages/astro/test/fixtures/routing-priority/src/pages/[page].astro deleted file mode 100644 index 74f5d463f2ad..000000000000 --- a/packages/astro/test/fixtures/routing-priority/src/pages/[page].astro +++ /dev/null @@ -1,27 +0,0 @@ ---- -export async function getStaticPaths() { - return [ - { - params: { page: "page-1" } - }, - { - params: { page: "page-2" } - } - ] - } ---- - - - - - - - Routing - - - -

[page].astro

-

{Astro.params.page}

- - - diff --git a/packages/astro/test/fixtures/routing-priority/src/pages/[slug].astro b/packages/astro/test/fixtures/routing-priority/src/pages/[slug].astro deleted file mode 100644 index 55e8161bb4d2..000000000000 --- a/packages/astro/test/fixtures/routing-priority/src/pages/[slug].astro +++ /dev/null @@ -1,27 +0,0 @@ ---- -export async function getStaticPaths() { - return [ - { - params: { slug: "slug-1" }, - }, - { - params: { slug: "slug-2" }, - } - ] - } ---- - - - - - - - Routing - - - -

[slug].astro

-

{Astro.params.slug}

- - - diff --git a/packages/astro/test/fixtures/routing-priority/src/pages/api/catch/[...slug].json.ts b/packages/astro/test/fixtures/routing-priority/src/pages/api/catch/[...slug].json.ts deleted file mode 100644 index 4b26f41e56a0..000000000000 --- a/packages/astro/test/fixtures/routing-priority/src/pages/api/catch/[...slug].json.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { APIRoute } from 'astro'; - -export const GET: APIRoute = async ({ params }) => { - return new Response( - JSON.stringify({ - path: params.slug, - }) - ); -}; - -export function getStaticPaths() { - return [{ params: { slug: 'a' } }, { params: { slug: 'b/c' } }]; -} diff --git a/packages/astro/test/fixtures/routing-priority/src/pages/api/catch/[foo]-[bar].json.ts b/packages/astro/test/fixtures/routing-priority/src/pages/api/catch/[foo]-[bar].json.ts deleted file mode 100644 index b9d2f0cabff1..000000000000 --- a/packages/astro/test/fixtures/routing-priority/src/pages/api/catch/[foo]-[bar].json.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { APIRoute } from 'astro'; - -export const GET: APIRoute = async ({ params }) => { - return new Response( - JSON.stringify({ - foo: params.foo, - bar: params.bar, - }) - ); -}; - -export function getStaticPaths() { - return [{ params: { foo: 'a', bar: 'b' } }]; -} diff --git a/packages/astro/test/fixtures/routing-priority/src/pages/de/index.astro b/packages/astro/test/fixtures/routing-priority/src/pages/de/index.astro deleted file mode 100644 index 9a82b84b988f..000000000000 --- a/packages/astro/test/fixtures/routing-priority/src/pages/de/index.astro +++ /dev/null @@ -1,14 +0,0 @@ ---- -// This route should take priority over src/pages/[lang]/index.astro ---- - - - - - Routing - - -

de/index.astro (priority)

-

de

- - diff --git a/packages/astro/test/fixtures/routing-priority/src/pages/empty-paths/[...slug].astro b/packages/astro/test/fixtures/routing-priority/src/pages/empty-paths/[...slug].astro deleted file mode 100644 index 67130d51e8e7..000000000000 --- a/packages/astro/test/fixtures/routing-priority/src/pages/empty-paths/[...slug].astro +++ /dev/null @@ -1,18 +0,0 @@ ---- -export function getStaticPaths() { - return [] -} -const { slug } = Astro.params ---- - - - - - - {slug} - - -

empty-paths/[...slug].astro

-

slug: {slug}

- - diff --git a/packages/astro/test/fixtures/routing-priority/src/pages/empty-slug/[...slug].astro b/packages/astro/test/fixtures/routing-priority/src/pages/empty-slug/[...slug].astro deleted file mode 100644 index da548ce4014c..000000000000 --- a/packages/astro/test/fixtures/routing-priority/src/pages/empty-slug/[...slug].astro +++ /dev/null @@ -1,22 +0,0 @@ ---- -export function getStaticPaths() { - return [{ - params: { slug: undefined }, - }, { - params: { slug: 'potato' }, - }] -} -const { slug } = Astro.params ---- - - - - - - {slug} - - -

empty-slug/[...slug].astro

-

slug: {slug}

- - diff --git a/packages/astro/test/fixtures/routing-priority/src/pages/index.astro b/packages/astro/test/fixtures/routing-priority/src/pages/index.astro deleted file mode 100644 index b89f4ab033fd..000000000000 --- a/packages/astro/test/fixtures/routing-priority/src/pages/index.astro +++ /dev/null @@ -1,12 +0,0 @@ ---- ---- - - - - - Routing - - -

index.astro

- - diff --git a/packages/astro/test/fixtures/routing-priority/src/pages/posts/[...slug].astro b/packages/astro/test/fixtures/routing-priority/src/pages/posts/[...slug].astro deleted file mode 100644 index a3a57b006af4..000000000000 --- a/packages/astro/test/fixtures/routing-priority/src/pages/posts/[...slug].astro +++ /dev/null @@ -1,24 +0,0 @@ ---- -export async function getStaticPaths() { - return [ - { - params: { slug: "1/2" }, - } - ] - } ---- - - - - - - - Routing - - - -

posts/[...slug].astro

-

{Astro.params.slug}

- - - diff --git a/packages/astro/test/fixtures/routing-priority/src/pages/posts/[pid].astro b/packages/astro/test/fixtures/routing-priority/src/pages/posts/[pid].astro deleted file mode 100644 index a9245b5ba1e8..000000000000 --- a/packages/astro/test/fixtures/routing-priority/src/pages/posts/[pid].astro +++ /dev/null @@ -1,23 +0,0 @@ ---- -export async function getStaticPaths() { - return [ - { params: { pid: 'post-1' } }, - { params: { pid: 'post-2' } } - ] - } ---- - - - - - - - Routing - - - -

posts/[pid].astro

-

{Astro.params.pid}

- - - diff --git a/packages/astro/test/fixtures/routing-priority/src/to-inject.astro b/packages/astro/test/fixtures/routing-priority/src/to-inject.astro deleted file mode 100644 index 4c5b6428f48d..000000000000 --- a/packages/astro/test/fixtures/routing-priority/src/to-inject.astro +++ /dev/null @@ -1,12 +0,0 @@ ---- ---- - - - - - Routing - - -

to-inject.astro

- - diff --git a/packages/astro/test/fixtures/server-islands/ssr/src/components/ScriptedCounter.astro b/packages/astro/test/fixtures/server-islands/ssr/src/components/ScriptedCounter.astro new file mode 100644 index 000000000000..35bb766b5d43 --- /dev/null +++ b/packages/astro/test/fixtures/server-islands/ssr/src/components/ScriptedCounter.astro @@ -0,0 +1,17 @@ +--- +--- +
+ 0 + +
+ + diff --git a/packages/astro/test/fixtures/server-islands/ssr/src/components/Wrapper.astro b/packages/astro/test/fixtures/server-islands/ssr/src/components/Wrapper.astro new file mode 100644 index 000000000000..f4c3cfc62527 --- /dev/null +++ b/packages/astro/test/fixtures/server-islands/ssr/src/components/Wrapper.astro @@ -0,0 +1,5 @@ +--- +--- +
+ +
diff --git a/packages/astro/test/fixtures/server-islands/ssr/src/pages/slot-with-script.astro b/packages/astro/test/fixtures/server-islands/ssr/src/pages/slot-with-script.astro new file mode 100644 index 000000000000..2db634722b90 --- /dev/null +++ b/packages/astro/test/fixtures/server-islands/ssr/src/pages/slot-with-script.astro @@ -0,0 +1,15 @@ +--- +import Wrapper from '../components/Wrapper.astro'; +import ScriptedCounter from '../components/ScriptedCounter.astro'; +--- + + + Slot with script + + + + +
Loading...
+
+ + diff --git a/packages/astro/test/fixtures/user-route-priority/package.json b/packages/astro/test/fixtures/user-route-priority/package.json deleted file mode 100644 index b175c228628c..000000000000 --- a/packages/astro/test/fixtures/user-route-priority/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "@test/user-route-priority", - "version": "0.0.0", - "private": true, - "dependencies": { - "astro": "workspace:*" - } -} diff --git a/packages/astro/test/fixtures/user-route-priority/public/favicon.ico b/packages/astro/test/fixtures/user-route-priority/public/favicon.ico deleted file mode 100644 index 578ad458b890..000000000000 Binary files a/packages/astro/test/fixtures/user-route-priority/public/favicon.ico and /dev/null differ diff --git a/packages/astro/test/fixtures/user-route-priority/src/pages/[number].astro b/packages/astro/test/fixtures/user-route-priority/src/pages/[number].astro deleted file mode 100644 index aa02e65c314e..000000000000 --- a/packages/astro/test/fixtures/user-route-priority/src/pages/[number].astro +++ /dev/null @@ -1,12 +0,0 @@ ---- -const val = Number(Astro.params.number); ---- - - - Test app - - - -

{ val }

- - diff --git a/packages/astro/test/fixtures/virtual-routes/astro.config.mjs b/packages/astro/test/fixtures/virtual-routes/astro.config.mjs deleted file mode 100644 index 37a31e918d05..000000000000 --- a/packages/astro/test/fixtures/virtual-routes/astro.config.mjs +++ /dev/null @@ -1,6 +0,0 @@ -import testAdapter from '../../test-adapter.js'; - -export default { - output: 'server', - adapter: testAdapter(), -}; diff --git a/packages/astro/test/fixtures/virtual-routes/package.json b/packages/astro/test/fixtures/virtual-routes/package.json deleted file mode 100644 index 1e11618c71e6..000000000000 --- a/packages/astro/test/fixtures/virtual-routes/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "@test/virtual-routes", - "dependencies": { - "astro": "workspace:*" - } - } - \ No newline at end of file diff --git a/packages/astro/test/fixtures/virtual-routes/src/middleware.js b/packages/astro/test/fixtures/virtual-routes/src/middleware.js deleted file mode 100644 index 96b626601e7a..000000000000 --- a/packages/astro/test/fixtures/virtual-routes/src/middleware.js +++ /dev/null @@ -1,8 +0,0 @@ -export function onRequest (context, next) { - if (context.request.url.includes('/virtual')) { - return new Response('Virtual!!', { - status: 200, - }); - } - return next() -} diff --git a/packages/astro/test/image-deletion.test.js b/packages/astro/test/image-deletion.test.js index 9af94a7543e1..b89d2551617d 100644 --- a/packages/astro/test/image-deletion.test.js +++ b/packages/astro/test/image-deletion.test.js @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import { before, describe, it } from 'node:test'; +import testAdapter from './test-adapter.js'; import { testImageService } from './test-image-service.js'; import { loadFixture } from './test-utils.js'; @@ -44,4 +45,34 @@ describe('astro:assets - delete images that are unused', () => { assert.equal(imagesUsedElsewhere.length, 2); }); }); + + describe('build ssr', () => { + before(async () => { + fixture = await loadFixture({ + root: './fixtures/core-image-deletion-ssr/', + output: 'server', + adapter: testAdapter(), + image: { + service: testImageService(), + }, + }); + + await fixture.build(); + }); + + it('should delete prerendered images that are only used for optimization', async () => { + const imagesOnlyOptimized = await fixture.glob('client/_astro/onlyone.*.*'); + assert.equal(imagesOnlyOptimized.length, 1); + }); + + it('should not delete prerendered images that are used in other contexts', async () => { + const imagesUsedElsewhere = await fixture.glob('client/_astro/twoofus.*.*'); + assert.equal(imagesUsedElsewhere.length, 2); + }); + + it('should not delete images that are used in both a prerendered and an SSR page', async () => { + const imagesUsedInBoth = await fixture.glob('client/_astro/shared.*.*'); + assert.equal(imagesUsedInBoth.length, 2); + }); + }); }); diff --git a/packages/astro/test/jsx-queue-rendering.test.js b/packages/astro/test/jsx-queue-rendering.test.js new file mode 100644 index 000000000000..1f821cda258c --- /dev/null +++ b/packages/astro/test/jsx-queue-rendering.test.js @@ -0,0 +1,71 @@ +import assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import { loadFixture } from './test-utils.js'; + +describe('JSX Queue Rendering', () => { + describe('Output comparison', () => { + let fixtureQueue; + let fixtureString; + + before(async () => { + // Build with queue rendering enabled (includes JSX queue rendering) + fixtureQueue = await loadFixture({ + root: './fixtures/jsx-queue-rendering/', + outDir: './dist/queue', + experimental: { + queuedRendering: { + enabled: true, + }, + }, + }); + await fixtureQueue.build(); + + // Build with queue rendering disabled (uses traditional string-based rendering) + fixtureString = await loadFixture({ + root: './fixtures/jsx-queue-rendering/', + outDir: './dist/string', + experimental: { + queuedRendering: { + enabled: false, + }, + }, + }); + await fixtureString.build(); + }); + + it('simple.html should match between queue and string rendering', async () => { + const queueHtml = await fixtureQueue.readFile('/simple/index.html'); + const stringHtml = await fixtureString.readFile('/simple/index.html'); + + assert.equal(queueHtml, stringHtml, 'simple.html output should be identical'); + }); + + it('nested.html should match between queue and string rendering', async () => { + const queueHtml = await fixtureQueue.readFile('/nested/index.html'); + const stringHtml = await fixtureString.readFile('/nested/index.html'); + + assert.equal(queueHtml, stringHtml, 'nested.html output should be identical'); + }); + + it('special-elements.html should match between queue and string rendering', async () => { + const queueHtml = await fixtureQueue.readFile('/special-elements/index.html'); + const stringHtml = await fixtureString.readFile('/special-elements/index.html'); + + assert.equal(queueHtml, stringHtml, 'special-elements.html output should be identical'); + }); + + it('mdx-simple.html should match between queue and string rendering', async () => { + const queueHtml = await fixtureQueue.readFile('/mdx-simple/index.html'); + const stringHtml = await fixtureString.readFile('/mdx-simple/index.html'); + + assert.equal(queueHtml, stringHtml, 'mdx-simple.html output should be identical'); + }); + + it('mdx-nested.html should match between queue and string rendering', async () => { + const queueHtml = await fixtureQueue.readFile('/mdx-nested/index.html'); + const stringHtml = await fixtureString.readFile('/mdx-nested/index.html'); + + assert.equal(queueHtml, stringHtml, 'mdx-nested.html output should be identical'); + }); + }); +}); diff --git a/packages/astro/test/preview-routing.test.js b/packages/astro/test/preview-routing.test.js deleted file mode 100644 index 05be4e8869e1..000000000000 --- a/packages/astro/test/preview-routing.test.js +++ /dev/null @@ -1,439 +0,0 @@ -import assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import * as cheerio from 'cheerio'; -import { loadFixture } from './test-utils.js'; - -describe('Preview Routing', () => { - describe('build format: directory', () => { - describe('Subpath without trailing slash and trailingSlash: never', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - /** @type {import('./test-utils').PreviewServer} */ - let previewServer; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/with-subpath-no-trailing-slash/', - base: '/blog', - outDir: './dist-4000', - build: { - format: 'directory', - }, - trailingSlash: 'never', - server: { - port: 4000, - }, - }); - await fixture.build(); - previewServer = await fixture.preview(); - }); - - after(async () => { - await previewServer.stop(); - }); - - it('404 when loading /', async () => { - const response = await fixture.fetch('/'); - assert.equal(response.status, 404); - }); - - it('200 when loading subpath root with trailing slash', async () => { - const response = await fixture.fetch('/blog/'); - assert.equal(response.status, 200); - assert.equal(response.redirected, false); - }); - - it('200 when loading subpath root without trailing slash', async () => { - const response = await fixture.fetch('/blog'); - assert.equal(response.status, 200); - assert.equal(response.redirected, false); - }); - - it('404 when loading another page with subpath used', async () => { - const response = await fixture.fetch('/blog/another/'); - assert.equal(response.status, 404); - }); - - it('200 when loading dynamic route', async () => { - const response = await fixture.fetch('/blog/1'); - assert.equal(response.status, 200); - }); - - it('404 when loading invalid dynamic route', async () => { - const response = await fixture.fetch('/blog/2'); - assert.equal(response.status, 404); - }); - }); - - describe('Subpath without trailing slash and trailingSlash: always', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - /** @type {import('./test-utils').PreviewServer} */ - let previewServer; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/with-subpath-no-trailing-slash/', - base: '/blog', - outDir: './dist-4001', - trailingSlash: 'always', - server: { - port: 4001, - }, - }); - await fixture.build(); - previewServer = await fixture.preview(); - }); - - after(async () => { - await previewServer.stop(); - }); - - it('404 when loading /', async () => { - const response = await fixture.fetch('/'); - assert.equal(response.status, 404); - }); - - it('200 when loading subpath root with trailing slash', async () => { - const response = await fixture.fetch('/blog/'); - assert.equal(response.status, 200); - }); - - it('404 when loading subpath root without trailing slash', async () => { - const response = await fixture.fetch('/blog'); - assert.equal(response.status, 404); - }); - - it('200 when loading another page with subpath used', async () => { - const response = await fixture.fetch('/blog/another/'); - assert.equal(response.status, 200); - }); - - it('404 when loading another page with subpath not used', async () => { - const response = await fixture.fetch('/blog/another'); - assert.equal(response.status, 404); - }); - - it('200 when loading dynamic route', async () => { - const response = await fixture.fetch('/blog/1/'); - assert.equal(response.status, 200); - }); - - it('404 when loading invalid dynamic route', async () => { - const response = await fixture.fetch('/blog/2/'); - assert.equal(response.status, 404); - }); - }); - - describe('Subpath without trailing slash and trailingSlash: ignore', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - /** @type {import('./test-utils').PreviewServer} */ - let previewServer; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/with-subpath-no-trailing-slash/', - base: '/blog', - outDir: './dist-4002', - trailingSlash: 'ignore', - server: { - port: 4002, - }, - }); - await fixture.build(); - previewServer = await fixture.preview(); - }); - - after(async () => { - await previewServer.stop(); - }); - - it('404 when loading /', async () => { - const response = await fixture.fetch('/'); - assert.equal(response.status, 404); - }); - - it('200 when loading subpath root with trailing slash', async () => { - const response = await fixture.fetch('/blog/'); - assert.equal(response.status, 200); - }); - - it('200 when loading subpath root without trailing slash', async () => { - const response = await fixture.fetch('/blog'); - assert.equal(response.status, 200); - }); - - it('200 when loading another page with subpath used', async () => { - const response = await fixture.fetch('/blog/another/'); - assert.equal(response.status, 200); - }); - - it('200 when loading another page with subpath not used', async () => { - const response = await fixture.fetch('/blog/another'); - assert.equal(response.status, 200); - }); - - it('200 when loading dynamic route', async () => { - const response = await fixture.fetch('/blog/1/'); - assert.equal(response.status, 200); - }); - - it('404 when loading invalid dynamic route', async () => { - const response = await fixture.fetch('/blog/2/'); - assert.equal(response.status, 404); - }); - }); - - describe('Load custom 404.html', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - /** @type {import('./test-utils').PreviewServer} */ - let previewServer; - - let $; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/custom-404-html/', - server: { - port: 4003, - }, - }); - await fixture.build(); - previewServer = await fixture.preview(); - }); - - after(async () => { - await previewServer.stop(); - }); - - it('renders custom 404 for /a', async () => { - const res = await fixture.fetch('/a'); - assert.equal(res.status, 404); - - const html = await res.text(); - $ = cheerio.load(html); - - assert.equal($('h1').text(), 'Page not found'); - assert.equal($('p').text(), 'This 404 is a static HTML file.'); - }); - }); - }); - - describe('build format: file', () => { - describe('Subpath without trailing slash and trailingSlash: never', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - /** @type {import('./test-utils').PreviewServer} */ - let previewServer; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/with-subpath-no-trailing-slash/', - base: '/blog', - outDir: './dist-4003', - build: { - format: 'file', - }, - trailingSlash: 'never', - server: { - port: 4004, - }, - }); - await fixture.build(); - previewServer = await fixture.preview(); - }); - - after(async () => { - await previewServer.stop(); - }); - - it('404 when loading /', async () => { - const response = await fixture.fetch('/'); - assert.equal(response.status, 404); - }); - - it('200 when loading subpath root with trailing slash', async () => { - const response = await fixture.fetch('/blog/'); - assert.equal(response.status, 200); - assert.equal(response.redirected, false); - }); - - it('200 when loading subpath root without trailing slash', async () => { - const response = await fixture.fetch('/blog'); - assert.equal(response.status, 200); - assert.equal(response.redirected, false); - }); - - it('404 when loading another page with subpath used', async () => { - const response = await fixture.fetch('/blog/another/'); - assert.equal(response.status, 404); - }); - - it('200 when loading dynamic route', async () => { - const response = await fixture.fetch('/blog/1'); - assert.equal(response.status, 200); - }); - - it('404 when loading invalid dynamic route', async () => { - const response = await fixture.fetch('/blog/2'); - assert.equal(response.status, 404); - }); - }); - - describe('Subpath without trailing slash and trailingSlash: ignore', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - /** @type {import('./test-utils').PreviewServer} */ - let previewServer; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/with-subpath-no-trailing-slash/', - base: '/blog', - outDir: './dist-4005', - build: { - format: 'file', - }, - trailingSlash: 'ignore', - server: { - port: 4006, - }, - }); - await fixture.build(); - previewServer = await fixture.preview(); - }); - - after(async () => { - await previewServer.stop(); - }); - - it('404 when loading /', async () => { - const response = await fixture.fetch('/'); - assert.equal(response.status, 404); - }); - - it('200 when loading subpath root with trailing slash', async () => { - const response = await fixture.fetch('/blog/'); - assert.equal(response.status, 200); - }); - - it('200 when loading subpath root without trailing slash', async () => { - const response = await fixture.fetch('/blog'); - assert.equal(response.status, 200); - }); - - it('200 when loading another page with subpath used', async () => { - const response = await fixture.fetch('/blog/another/'); - assert.equal(response.status, 200); - }); - - it('200 when loading another page with subpath not used', async () => { - const response = await fixture.fetch('/blog/another'); - assert.equal(response.status, 200); - }); - - it('200 when loading dynamic route', async () => { - const response = await fixture.fetch('/blog/1/'); - assert.equal(response.status, 200); - }); - - it('404 when loading invalid dynamic route', async () => { - const response = await fixture.fetch('/blog/2/'); - assert.equal(response.status, 404); - }); - }); - - describe('Exact file path', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - /** @type {import('./test-utils').PreviewServer} */ - let previewServer; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/with-subpath-no-trailing-slash/', - base: '/blog', - outDir: './dist-4006', - build: { - format: 'file', - }, - trailingSlash: 'ignore', - server: { - port: 4007, - }, - }); - await fixture.build(); - previewServer = await fixture.preview(); - }); - - after(async () => { - await previewServer.stop(); - }); - - it('404 when loading /', async () => { - const response = await fixture.fetch('/'); - assert.equal(response.status, 404); - }); - - it('200 when loading subpath with index.html', async () => { - const response = await fixture.fetch('/blog/index.html'); - assert.equal(response.status, 200); - }); - - it('200 when loading another page with subpath used', async () => { - const response = await fixture.fetch('/blog/another.html'); - assert.equal(response.status, 200); - }); - - it('200 when loading dynamic route', async () => { - const response = await fixture.fetch('/blog/1.html'); - assert.equal(response.status, 200); - }); - - it('404 when loading invalid dynamic route', async () => { - const response = await fixture.fetch('/blog/2.html'); - assert.equal(response.status, 404); - }); - }); - - describe('Load custom 404.html', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - /** @type {import('./test-utils').PreviewServer} */ - let previewServer; - - let $; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/custom-404-html/', - build: { - format: 'file', - }, - server: { - port: 4008, - }, - }); - await fixture.build(); - previewServer = await fixture.preview(); - }); - - after(async () => { - await previewServer.stop(); - }); - - it('renders custom 404 for /a', async () => { - const res = await fixture.fetch('/a'); - assert.equal(res.status, 404); - - const html = await res.text(); - $ = cheerio.load(html); - - assert.equal($('h1').text(), 'Page not found'); - assert.equal($('p').text(), 'This 404 is a static HTML file.'); - }); - }); - }); -}); diff --git a/packages/astro/test/queue-rendering.test.js b/packages/astro/test/queue-rendering.test.js new file mode 100644 index 000000000000..f375ceb81ecd --- /dev/null +++ b/packages/astro/test/queue-rendering.test.js @@ -0,0 +1,299 @@ +import assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import { loadFixture } from './test-utils.js'; + +describe('Queue-based rendering - Static', () => { + /** @type {import('./test-utils.js').Fixture} */ + let fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/queue-rendering/', + output: 'static', + }); + await fixture.build(); + }); + + describe('Basic rendering', () => { + it('should render index page successfully', async () => { + const html = await fixture.readFile('/index.html'); + + // Verify basic structure + assert.ok(html.includes('Queue Rendering Test')); + assert.ok(html.includes('

Queue Rendering Test

')); + }); + + it('should render simple text and primitives correctly', async () => { + const html = await fixture.readFile('/index.html'); + + assert.ok(html.includes('

Simple text rendering

')); + assert.ok(html.includes('

Number: 42

')); + assert.ok(html.includes('

Boolean: true

')); + }); + + it('should render arrays correctly', async () => { + const html = await fixture.readFile('/index.html'); + + assert.ok(html.includes('
  • First
  • ')); + assert.ok(html.includes('
  • Second
  • ')); + assert.ok(html.includes('
  • Third
  • ')); + + // Verify order + const firstPos = html.indexOf('
  • First
  • '); + const secondPos = html.indexOf('
  • Second
  • '); + const thirdPos = html.indexOf('
  • Third
  • '); + + assert.ok(firstPos < secondPos); + assert.ok(secondPos < thirdPos); + }); + + it('should render multiple component instances correctly', async () => { + const html = await fixture.readFile('/index.html'); + + assert.ok(html.includes('data-level="0"')); + assert.ok(html.includes('data-level="1"')); + assert.ok(html.includes('data-level="2"')); + + assert.ok(html.includes('Level 0')); + assert.ok(html.includes('Level 1')); + assert.ok(html.includes('Level 2')); + }); + + it('should render components with slots correctly', async () => { + const html = await fixture.readFile('/index.html'); + + assert.ok(html.includes('class="with-slot"')); + assert.ok(html.includes('

    Test Title

    ')); + assert.ok(html.includes('class="slot-content"')); + assert.ok(html.includes('

    Slot content here

    ')); + assert.ok(html.includes('

    Multiple paragraphs

    ')); + }); + }); + + describe('Astro directives', () => { + it('should handle set:html directive', async () => { + const html = await fixture.readFile('/directives/index.html'); + + // set:html should render raw HTML + assert.ok(html.includes('Bold text from set:html')); + }); + + it('should handle set:text directive', async () => { + const html = await fixture.readFile('/directives/index.html'); + + // set:text should escape HTML + assert.ok(html.includes('<em>This should be escaped</em>')); + }); + + it('should handle class:list directive', async () => { + const html = await fixture.readFile('/directives/index.html'); + + // class:list should merge classes correctly + assert.ok(html.includes('class="foo bar baz"')); + }); + + it('should handle inline style objects', async () => { + const html = await fixture.readFile('/directives/index.html'); + + // Style object should be converted to inline CSS + assert.ok(html.includes('color:red') || html.includes('color: red')); + assert.ok(html.includes('font-size:20px') || html.includes('font-size: 20px')); + }); + }); + + describe('Client components', () => { + it('should render client:load components', async () => { + const html = await fixture.readFile('/client-components/index.html'); + + // Should include the component HTML + assert.ok(html.includes('class="counter"')); + // React adds HTML comments, so check for the number separately + assert.ok(html.includes('>5<') || html.includes('5')); + + // Should include hydration script + assert.ok(html.includes('astro-island')); + assert.ok(html.includes('client:load')); + }); + + it('should render client:idle components', async () => { + const html = await fixture.readFile('/client-components/index.html'); + + assert.ok(html.includes('>10<') || html.includes('10')); + assert.ok(html.includes('client:idle')); + }); + + it('should render client:visible components', async () => { + const html = await fixture.readFile('/client-components/index.html'); + + assert.ok(html.includes('>15<') || html.includes('15')); + assert.ok(html.includes('client:visible')); + }); + + it('should render client:media components', async () => { + const html = await fixture.readFile('/client-components/index.html'); + + assert.ok(html.includes('>20<') || html.includes('20')); + assert.ok(html.includes('client:media')); + }); + + it('should render client:only components', async () => { + const html = await fixture.readFile('/client-components/index.html'); + + // client:only should not render on server + // The component placeholder should exist but not the SSR content + assert.ok(html.includes('client:only')); + }); + + it('should render static components without hydration', async () => { + const html = await fixture.readFile('/client-components/index.html'); + + // Static component should render but not have hydration + assert.ok(html.includes('Server-side only')); + assert.ok(html.includes('class="static-component"')); + }); + }); + + describe('Head content', () => { + it('should include inline styles in head', async () => { + const html = await fixture.readFile('/head-content/index.html'); + + // Inline styles should be hoisted to head or remain inline + assert.ok(html.includes('.inline-test')); + assert.ok(html.includes('color: green') || html.includes('color:green')); + }); + + it('should include component styles in head', async () => { + const html = await fixture.readFile('/head-content/index.html'); + + // Component styles should be in head + assert.ok(html.includes('.with-head')); + assert.ok(html.includes('border: 1px solid blue') || html.includes('border:1px solid blue')); + }); + + it('should include component scripts', async () => { + const html = await fixture.readFile('/head-content/index.html'); + + // Component scripts should be included + assert.ok(html.includes('WithHead script loaded')); + }); + + it('should include inline scripts', async () => { + const html = await fixture.readFile('/head-content/index.html'); + + // Inline scripts with is:inline should be included + assert.ok(html.includes('Inline script executed')); + }); + }); +}); + +describe('Queue-based rendering - SSR', () => { + /** @type {import('./test-utils.js').Fixture} */ + let fixture; + /** @type {import('./test-utils.js').App} */ + let app; + + before(async () => { + // Note: In SSR mode (output: 'server'), pooling is automatically disabled + // because AppPipeline sets disablePooling: true in the render context. + // This is correct behavior since pooling provides no benefit in SSR + // where each request is independent. + fixture = await loadFixture({ + root: './fixtures/queue-rendering/', + output: 'server', + adapter: await import('./test-adapter.js').then((mod) => mod.default()), + }); + await fixture.build(); + app = await fixture.loadTestAdapterApp(); + }); + + it('should render SSR page with queue rendering', async () => { + const request = new Request('http://example.com/'); + const response = await app.render(request); + const html = await response.text(); + + assert.ok(html.includes('Queue Rendering Test')); + assert.ok(html.includes('

    Queue Rendering Test

    ')); + }); + + it('should render directives page in SSR', async () => { + const request = new Request('http://example.com/directives'); + const response = await app.render(request); + const html = await response.text(); + + // set:html should render raw HTML + assert.ok(html.includes('Bold text from set:html')); + + // set:text should escape HTML + assert.ok(html.includes('<em>This should be escaped</em>')); + + // class:list should merge classes + assert.ok(html.includes('class="foo bar baz"')); + }); + + it('should render client components in SSR', async () => { + const request = new Request('http://example.com/client-components'); + const response = await app.render(request); + const html = await response.text(); + + // Should include the component HTML with SSR content + assert.ok(html.includes('class="counter"')); + // React adds HTML comments, so check for the number separately + assert.ok(html.includes('>5<') || html.includes('5')); + + // Should include hydration islands + assert.ok(html.includes('astro-island')); + assert.ok(html.includes('client:load')); + }); + + it('should render head content in SSR', async () => { + const request = new Request('http://example.com/head-content'); + const response = await app.render(request); + const html = await response.text(); + + // Component styles should be in head + assert.ok(html.includes('.with-head')); + + // Inline scripts should be included + assert.ok(html.includes('Inline script executed')); + }); +}); + +describe('Queue-based rendering - Configuration', () => { + it('should support custom pool size configuration', async () => { + const fixture = await loadFixture({ + root: './fixtures/queue-rendering/', + output: 'static', + experimental: { + queuedRendering: { + poolSize: 500, + }, + }, + }); + await fixture.build(); + + const html = await fixture.readFile('/index.html'); + + // Verify basic rendering still works with custom pool size + assert.ok(html.includes('

    Queue Rendering Test

    ')); + assert.ok(html.includes('

    Simple text rendering

    ')); + }); + + it('should support object configuration', async () => { + const fixture = await loadFixture({ + root: './fixtures/queue-rendering/', + output: 'static', + experimental: { + queuedRendering: { + enabled: true, + }, + }, + }); + await fixture.build(); + + const html = await fixture.readFile('/index.html'); + + // Verify rendering works with boolean config + assert.ok(html.includes('

    Queue Rendering Test

    ')); + assert.ok(html.includes('

    Simple text rendering

    ')); + }); +}); diff --git a/packages/astro/test/route-manifest.test.js b/packages/astro/test/route-manifest.test.js deleted file mode 100644 index 16940483389a..000000000000 --- a/packages/astro/test/route-manifest.test.js +++ /dev/null @@ -1,241 +0,0 @@ -// TODO: unskip this test -// import { expect } from 'chai'; -// import { fileURLToPath } from 'node:url'; -// import { createRouteManifest } from '../dist/core/routing/index.js'; -// import { validateConfig } from '../dist/core/config.js'; - -// const cwd = new URL('./fixtures/route-manifest/', import.meta.url); - -// const create = async (dir, trailingSlash) => { -// return createRouteManifest({ -// config: await validateConfig({ -// root: cwd.pathname, -// trailingSlash, -// }), -// cwd: fileURLToPath(cwd), -// }); -// }; -// function cleanRoutes(routes) { -// return routes.map((r) => { -// delete r.generate; -// return r; -// }); -// } - -// describe('route manifest', () => { -// it('creates routes with trailingSlashes = always', async () => { -// const { routes } = await create('basic', 'always'); -// expect(cleanRoutes(routes)).to.deep.equal([ -// { -// type: 'page', -// pattern: /^\/$/, -// params: [], -// component: 'basic/index.astro', -// pathname: '/', -// }, - -// { -// type: 'page', -// pattern: /^\/about\/$/, -// params: [], -// component: 'basic/about.astro', -// pathname: '/about', -// }, - -// { -// type: 'page', -// pattern: /^\/blog\/$/, -// params: [], -// component: 'basic/blog/index.astro', -// pathname: '/blog', -// }, - -// { -// type: 'page', -// pattern: /^\/blog\/([^/]+?)\/$/, -// params: ['slug'], -// component: 'basic/blog/[slug].astro', -// pathname: undefined, -// }, -// ]); -// }); - -// it('creates routes with trailingSlashes = never', async () => { -// const { routes } = await create('basic', 'never'); -// expect(cleanRoutes(routes)).to.deep.equal([ -// { -// type: 'page', -// pattern: /^\/$/, -// params: [], -// component: 'basic/index.astro', -// pathname: '/', -// }, - -// { -// type: 'page', -// pattern: /^\/about$/, -// params: [], -// component: 'basic/about.astro', -// pathname: '/about', -// }, - -// { -// type: 'page', -// pattern: /^\/blog$/, -// params: [], -// component: 'basic/blog/index.astro', -// pathname: '/blog', -// }, - -// { -// type: 'page', -// pattern: /^\/blog\/([^/]+?)$/, -// params: ['slug'], -// component: 'basic/blog/[slug].astro', -// pathname: undefined, -// }, -// ]); -// }); - -// it('creates routes with trailingSlashes = ignore', async () => { -// const { routes } = await create('basic', 'ignore'); -// expect(cleanRoutes(routes)).to.deep.equal([ -// { -// type: 'page', -// pattern: /^\/$/, -// params: [], -// component: 'basic/index.astro', -// pathname: '/', -// }, - -// { -// type: 'page', -// pattern: /^\/about\/?$/, -// params: [], -// component: 'basic/about.astro', -// pathname: '/about', -// }, - -// { -// type: 'page', -// pattern: /^\/blog\/?$/, -// params: [], -// component: 'basic/blog/index.astro', -// pathname: '/blog', -// }, - -// { -// type: 'page', -// pattern: /^\/blog\/([^/]+?)\/?$/, -// params: ['slug'], -// component: 'basic/blog/[slug].astro', -// pathname: undefined, -// }, -// ]); -// }); - -// it('encodes invalid characters', async () => { -// const { routes } = await create('encoding', 'always'); - -// // had to remove ? and " because windows - -// // const quote = 'encoding/".astro'; -// const hash = 'encoding/#.astro'; -// // const question_mark = 'encoding/?.astro'; - -// expect(routes.map((p) => p.pattern)).to.deep.equal([ -// // /^\/%22$/, -// /^\/%23\/$/, -// // /^\/%3F$/ -// ]); -// }); - -// it('ignores files and directories with leading underscores', async () => { -// const { routes } = await create('hidden-underscore', 'always'); - -// expect(routes.map((r) => r.component).filter(Boolean)).to.deep.equal(['hidden-underscore/index.astro', 'hidden-underscore/e/f/g/h.astro']); -// }); - -// it('ignores files and directories with leading dots except .well-known', async () => { -// const { routes } = await create('hidden-dot', 'always'); - -// expect(routes.map((r) => r.component).filter(Boolean)).to.deep.equal(['hidden-dot/.well-known/dnt-policy.astro']); -// }); - -// it('fails if dynamic params are not separated', async () => { -// expect(() => await create('invalid-params', 'always')).to.throw('Invalid route invalid-params/[foo][bar].astro — parameters must be separated'); -// }); - -// it('disallows rest parameters inside segments', async () => { -// expect(() => await create('invalid-rest', 'always')).to.throw('Invalid route invalid-rest/foo-[...rest]-bar.astro — rest parameter must be a standalone segment'); -// }); - -// it('ignores things that look like lockfiles', async () => { -// const { routes } = await create('lockfiles', 'always'); -// expect(cleanRoutes(routes)).to.deep.equal([ -// { -// type: 'page', -// pattern: /^\/foo\/$/, -// params: [], -// component: 'lockfiles/foo.astro', -// pathname: '/foo', -// }, -// ]); -// }); - -// it('ignores invalid route extensions', async () => { -// const { routes } = await create('invalid-extension', 'always'); -// expect(cleanRoutes(routes)).to.deep.equal([ -// { -// type: 'page', -// pattern: /^\/$/, -// params: [], -// component: 'invalid-extension/index.astro', -// pathname: '/', -// }, - -// { -// type: 'page', -// pattern: /^\/about\/$/, -// params: [], -// component: 'invalid-extension/about.astro', -// pathname: '/about', -// }, -// ]); -// }); - -// it('allows multiple slugs', async () => { -// const { routes } = await create('multiple-slugs', 'always'); - -// expect(cleanRoutes(routes)).to.deep.equal([ -// { -// type: 'page', -// pattern: /^\/([^/]+?)\.([^/]+?)\/$/, -// component: 'multiple-slugs/[file].[ext].astro', -// params: ['file', 'ext'], -// pathname: undefined, -// }, -// ]); -// }); - -// it('sorts routes correctly', async () => { -// const { routes } = await create('sorting', 'always'); - -// expect(routes.map((p) => p.component)).to.deep.equal([ -// 'sorting/index.astro', -// 'sorting/about.astro', -// 'sorting/post/index.astro', -// 'sorting/post/bar.astro', -// 'sorting/post/foo.astro', -// 'sorting/post/f[xx].astro', -// 'sorting/post/f[yy].astro', -// 'sorting/post/[id].astro', -// 'sorting/[wildcard].astro', -// 'sorting/[...rest]/deep/[...deep_rest]/xyz.astro', -// 'sorting/[...rest]/deep/[...deep_rest]/index.astro', -// 'sorting/[...rest]/deep/index.astro', -// 'sorting/[...rest]/abc.astro', -// 'sorting/[...rest]/index.astro', -// ]); -// }); -// }); diff --git a/packages/astro/test/routing-priority.test.js b/packages/astro/test/routing-priority.test.js deleted file mode 100644 index 888c28d1043a..000000000000 --- a/packages/astro/test/routing-priority.test.js +++ /dev/null @@ -1,270 +0,0 @@ -import assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import { load as cheerioLoad } from 'cheerio'; -import { loadFixture } from './test-utils.js'; - -const routes = [ - { - description: 'matches / to index.astro', - url: '/', - h1: 'index.astro', - }, - { - description: 'matches /slug-1 to [slug].astro', - url: '/slug-1', - h1: '[slug].astro', - p: 'slug-1', - }, - { - description: 'matches /slug-2 to [slug].astro', - url: '/slug-2', - h1: '[slug].astro', - p: 'slug-2', - }, - { - description: 'matches /page-1 to [page].astro', - url: '/page-1', - h1: '[page].astro', - p: 'page-1', - }, - { - description: 'matches /page-2 to [page].astro', - url: '/page-2', - h1: '[page].astro', - p: 'page-2', - }, - { - description: 'matches /posts/post-1 to posts/[pid].astro', - url: '/posts/post-1', - h1: 'posts/[pid].astro', - p: 'post-1', - }, - { - description: 'matches /posts/post-2 to posts/[pid].astro', - url: '/posts/post-2', - h1: 'posts/[pid].astro', - p: 'post-2', - }, - { - description: 'matches /posts/1/2 to posts/[...slug].astro', - url: '/posts/1/2', - h1: 'posts/[...slug].astro', - p: '1/2', - }, - { - description: 'matches /de to de/index.astro', - url: '/de', - h1: 'de/index.astro (priority)', - }, - { - description: 'matches /en to [lang]/index.astro', - url: '/en', - h1: '[lang]/index.astro', - p: 'en', - }, - { - description: 'matches /de/1/2 to [lang]/[...catchall].astro', - url: '/de/1/2', - h1: '[lang]/[...catchall].astro', - p: 'de | 1/2', - }, - { - description: 'matches /en/1/2 to [lang]/[...catchall].astro', - url: '/en/1/2', - h1: '[lang]/[...catchall].astro', - p: 'en | 1/2', - }, - { - description: 'matches /injected to to-inject.astro', - url: '/injected', - h1: 'to-inject.astro', - }, - { - description: 'matches /_injected to to-inject.astro', - url: '/_injected', - h1: 'to-inject.astro', - }, - { - description: 'matches /injected-1 to [id].astro', - url: '/injected-1', - h1: '[id].astro', - p: 'injected-1', - }, - { - description: 'matches /injected-2 to [id].astro', - url: '/injected-2', - h1: '[id].astro', - p: 'injected-2', - }, - { - description: 'matches /empty-slug to empty-slug/[...slug].astro', - url: '/empty-slug', - h1: 'empty-slug/[...slug].astro', - p: 'slug: ', - }, - { - description: 'do not match /empty-slug/undefined to empty-slug/[...slug].astro', - url: '/empty-slug/undefined', - fourOhFour: true, - }, - { - description: 'do not match /empty-paths/hello to empty-paths/[...slug].astro', - url: '/empty-paths/hello', - fourOhFour: true, - }, - { - description: 'matches /api/catch/a.json to api/catch/[...slug].json.ts', - url: '/api/catch/a.json', - htmlMatch: JSON.stringify({ path: 'a' }), - }, - { - description: 'matches /api/catch/b/c.json to api/catch/[...slug].json.ts', - url: '/api/catch/b/c.json', - htmlMatch: JSON.stringify({ path: 'b/c' }), - }, - { - description: 'matches /api/catch/a-b.json to api/catch/[foo]-[bar].json.ts', - url: '/api/catch/a-b.json', - htmlMatch: JSON.stringify({ foo: 'a', bar: 'b' }), - }, -]; - -function appendForwardSlash(path) { - return path.endsWith('/') ? path : path + '/'; -} - -describe('Routing priority', () => { - describe('build', () => { - let fixture; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/routing-priority/', - }); - await fixture.build(); - }); - - routes.forEach(({ description, url, fourOhFour, h1, p, htmlMatch }) => { - const isEndpoint = htmlMatch && !h1 && !p; - - it(description, async () => { - const htmlFile = isEndpoint ? url : `${appendForwardSlash(url)}index.html`; - - if (fourOhFour) { - assert.equal(fixture.pathExists(htmlFile), false); - return; - } - - const html = await fixture.readFile(htmlFile); - const $ = cheerioLoad(html); - - if (h1) { - assert.equal($('h1').text(), h1); - } - - if (p) { - assert.equal($('p').text(), p); - } - - if (htmlMatch) { - assert.equal(html, htmlMatch); - } - }); - }); - }); - - describe('dev', () => { - let fixture; - let devServer; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/routing-priority/', - }); - - devServer = await fixture.startDevServer(); - }); - - after(async () => { - await devServer.stop(); - }); - - routes.forEach(({ description, url, fourOhFour, h1, p, htmlMatch }) => { - const isEndpoint = htmlMatch && !h1 && !p; - - // checks URLs as written above - it(description, async () => { - const html = await fixture.fetch(url).then((res) => res.text()); - const $ = cheerioLoad(html); - - if (fourOhFour) { - assert.equal($('title').text(), '404: Not Found'); - return; - } - - if (h1) { - assert.equal($('h1').text(), h1); - } - - if (p) { - assert.equal($('p').text(), p); - } - - if (htmlMatch) { - assert.equal(html, htmlMatch); - } - }); - - // skip for endpoint page test - if (isEndpoint) return; - - // checks with trailing slashes, ex: '/de/' instead of '/de' - it(`${description} (trailing slash)`, async () => { - const html = await fixture.fetch(appendForwardSlash(url)).then((res) => res.text()); - const $ = cheerioLoad(html); - - if (fourOhFour) { - assert.equal($('title').text(), '404: Not Found'); - return; - } - - if (h1) { - assert.equal($('h1').text(), h1); - } - - if (p) { - assert.equal($('p').text(), p); - } - - if (htmlMatch) { - assert.equal(html, htmlMatch); - } - }); - - // checks with index.html, ex: '/de/index.html' instead of '/de' - it(`${description} (index.html)`, async () => { - const html = await fixture - .fetch(`${appendForwardSlash(url)}index.html`) - .then((res) => res.text()); - const $ = cheerioLoad(html); - - if (fourOhFour) { - assert.equal($('title').text(), '404: Not Found'); - return; - } - - if (h1) { - assert.equal($('h1').text(), h1); - } - - if (p) { - assert.equal($('p').text(), p); - } - - if (htmlMatch) { - assert.equal(html, htmlMatch); - } - }); - }); - }); -}); diff --git a/packages/astro/test/server-islands.test.js b/packages/astro/test/server-islands.test.js index 330441add0c1..5d0016867703 100644 --- a/packages/astro/test/server-islands.test.js +++ b/packages/astro/test/server-islands.test.js @@ -245,6 +245,26 @@ describe('Server islands', () => { assert.equal(fetchMatch.length, 2, 'should include props in the query string'); assert.equal(fetchMatch[1], '', 'should not include encrypted empty props'); }); + + it('includes script from slotted component in island response', async () => { + const res = await fixture.fetch('/slot-with-script'); + assert.equal(res.status, 200); + const html = await res.text(); + // Extract the island fetch URL from the page + const urlMatch = html.match(/fetch\('(\/_server-islands\/Wrapper\?[^']+)'/); + assert.ok(urlMatch, 'should have a server island fetch URL'); + const islandRes = await fixture.fetch(urlMatch[1]); + assert.equal(islandRes.status, 200); + const islandHtml = await islandRes.text(); + assert.ok( + islandHtml.includes(' { diff --git a/packages/astro/test/units/app/test-helpers.js b/packages/astro/test/units/app/test-helpers.js index d76a79d47b6a..1a7cb3c89997 100644 --- a/packages/astro/test/units/app/test-helpers.js +++ b/packages/astro/test/units/app/test-helpers.js @@ -51,6 +51,9 @@ export function createManifest({ routes, pageMap, base = '/', trailingSlash = 'i }, internalFetchHeaders: undefined, logLevel: /** @type {'silent'} */ ('silent'), + experimentalQueuedRendering: { + enabled: false, + }, }; } diff --git a/packages/astro/test/units/render/queue-batching.test.js b/packages/astro/test/units/render/queue-batching.test.js new file mode 100644 index 000000000000..db56cf242198 --- /dev/null +++ b/packages/astro/test/units/render/queue-batching.test.js @@ -0,0 +1,169 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { buildRenderQueue } from '../../../dist/runtime/server/render/queue/builder.js'; +import { renderQueue } from '../../../dist/runtime/server/render/queue/renderer.js'; +import { NodePool } from '../../../dist/runtime/server/render/queue/pool.js'; +import { markHTMLString } from '../../../dist/runtime/server/index.js'; + +// Mock SSRResult for testing +function createMockResult() { + return { + _metadata: { + hasHydrationScript: false, + hasRenderedHead: false, + hasDirectives: new Set(), + headInTree: false, + extraHead: [], + propagators: new Set(), + }, + styles: new Set(), + scripts: new Set(), + links: new Set(), + }; +} + +// Create a NodePool for testing +function createMockPool() { + return new NodePool(1000); +} + +describe('Queue batching optimization', () => { + it('should batch consecutive text nodes', async () => { + const result = createMockResult(); + const pool = createMockPool(); + const items = ['Hello', ' ', 'world', '!']; + + const queue = await buildRenderQueue(items, result, pool); + + // All text nodes should be in the queue + assert.equal(queue.nodes.length, 4); + assert.equal( + queue.nodes.every((n) => n.type === 'text'), + true, + ); + + // When rendered, they should be batched into one write + let writeCount = 0; + let output = ''; + const destination = { + write(chunk) { + writeCount++; + output += String(chunk); + }, + }; + + await renderQueue(queue, destination); + + assert.equal(output, 'Hello world!'); + assert.equal(writeCount, 1); // All 4 nodes batched into 1 write! + }); + + it('should batch consecutive html-string nodes', async () => { + const result = createMockResult(); + const pool = createMockPool(); + + const items = [markHTMLString('
    '), markHTMLString('content'), markHTMLString('
    ')]; + + const queue = await buildRenderQueue(items, result, pool); + + let writeCount = 0; + let output = ''; + const destination = { + write(chunk) { + writeCount++; + output += String(chunk); + }, + }; + + await renderQueue(queue, destination); + + // Should batch into single write + assert.equal(writeCount, 1, 'Should batch consecutive html-string nodes'); + assert.equal(output, '
    content
    '); + }); + + it('should NOT batch across component boundaries', async () => { + const result = createMockResult(); + const pool = createMockPool(); + + // Create a simple component + const componentInstance = { + render(dest) { + dest.write('

    Component

    '); + }, + }; + + const items = ['before', componentInstance, 'after']; + + const queue = await buildRenderQueue(items, result, pool); + + let writeCount = 0; + let output = ''; + const destination = { + write(chunk) { + writeCount++; + output += String(chunk); + }, + }; + + await renderQueue(queue, destination); + + // Should have 3 writes: batched 'before', component output, batched 'after' + assert.equal(writeCount, 3, 'Should NOT batch across component boundaries'); + assert.equal(output, 'before

    Component

    after'); + }); + + it('should demonstrate performance improvement with large arrays', async () => { + const result = createMockResult(); + const pool = createMockPool(); + + // Create a large array of text items (simulating a list) + const items = Array.from({ length: 1000 }, (_, i) => `Item ${i + 1}`); + + const queue = await buildRenderQueue(items, result, pool); + + assert.equal(queue.nodes.length, 1000); + + let writeCount = 0; + const destination = { + write() { + writeCount++; + }, + }; + + await renderQueue(queue, destination); + + // With batching: 1 write (all text nodes batched together) + // Without batching: 1000 writes (one per node) + assert.equal(writeCount, 1, 'Should batch 1000 text nodes into 1 write (99.9% reduction!)'); + }); + + it('should batch mixed text and html-string nodes', async () => { + const result = createMockResult(); + const pool = createMockPool(); + + const items = [ + 'Text 1', + markHTMLString('Bold'), + 'Text 2', + markHTMLString('Italic'), + ]; + + const queue = await buildRenderQueue(items, result, pool); + + let writeCount = 0; + let output = ''; + const destination = { + write(chunk) { + writeCount++; + output += String(chunk); + }, + }; + + await renderQueue(queue, destination); + + // All should be batched since they're all batchable types + assert.equal(writeCount, 1); + assert.equal(output, 'Text 1BoldText 2Italic'); + }); +}); 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 new file mode 100644 index 000000000000..e68aef174d51 --- /dev/null +++ b/packages/astro/test/units/render/queue-pool-content-cache.test.js @@ -0,0 +1,178 @@ +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 new file mode 100644 index 000000000000..211c11c955e6 --- /dev/null +++ b/packages/astro/test/units/render/queue-pool-prewarming.test.js @@ -0,0 +1,165 @@ +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); + }); +}); diff --git a/packages/astro/test/units/render/queue-pool.test.js b/packages/astro/test/units/render/queue-pool.test.js new file mode 100644 index 000000000000..d811a7103dd1 --- /dev/null +++ b/packages/astro/test/units/render/queue-pool.test.js @@ -0,0 +1,138 @@ +import { describe, it } from 'node:test'; +import { strictEqual } from 'node:assert'; +import { NodePool } from '../../../dist/runtime/server/render/queue/pool.js'; + +describe('NodePool', () => { + it('should acquire a new node when pool is empty', () => { + const pool = new NodePool(); + const node = pool.acquire('text'); + + strictEqual(node.type, 'text'); + strictEqual(node.content, ''); // Default value for new TextNode + }); + + it('should reuse released nodes', () => { + const pool = new NodePool(); + + // Acquire and set up a node + const node1 = pool.acquire('text'); + node1.content = 'Hello'; + + // Release it back to the pool + pool.release(node1); + strictEqual(pool.size(), 1); + + // Acquire another node - with discriminated union, we create a fresh node + const node2 = pool.acquire('html-string'); + strictEqual(node2.type, 'html-string'); // Type is html-string + strictEqual(node2.html, ''); // Default value for new HtmlStringNode + + // Pool size should decrease (node was consumed from pool) + strictEqual(pool.size(), 0); + }); + + it('should respect maxSize limit', () => { + const pool = new NodePool(2); // Max size of 2 + + const node1 = pool.acquire('text'); + const node2 = pool.acquire('text'); + const node3 = pool.acquire('text'); + + pool.release(node1); + pool.release(node2); + pool.release(node3); // Should be discarded + + strictEqual(pool.size(), 2); // Only 2 nodes retained + }); + + it('should clear the pool', () => { + const pool = new NodePool(); + + // Acquire nodes first, then release them all at once + const node1 = pool.acquire('text'); + const node2 = pool.acquire('text'); + const node3 = pool.acquire('text'); + + pool.release(node1); + pool.release(node2); + pool.release(node3); + + strictEqual(pool.size(), 3); + + pool.clear(); + strictEqual(pool.size(), 0); + }); + + it('should release all nodes in an array', () => { + const pool = new NodePool(); + + const nodes = [pool.acquire('text'), pool.acquire('html-string'), pool.acquire('component')]; + + pool.releaseAll(nodes); + strictEqual(pool.size(), 3); + }); + + it('should properly create nodes with correct discriminated union types', () => { + const pool = new NodePool(); + + // Acquire different node types + const textNode = pool.acquire('text'); + const htmlNode = pool.acquire('html-string'); + const componentNode = pool.acquire('component'); + const instructionNode = pool.acquire('instruction'); + + // Each node should have only its relevant fields (discriminated union) + strictEqual(textNode.type, 'text'); + strictEqual(textNode.content, ''); + + strictEqual(htmlNode.type, 'html-string'); + strictEqual(htmlNode.html, ''); + + strictEqual(componentNode.type, 'component'); + strictEqual(componentNode.instance, undefined); + + strictEqual(instructionNode.type, 'instruction'); + strictEqual(instructionNode.instruction, undefined); + }); + + it('should handle multiple acquire/release cycles', () => { + const pool = new NodePool(10); + + // First cycle + const batch1 = []; + for (let i = 0; i < 5; i++) { + batch1.push(pool.acquire('text')); + } + pool.releaseAll(batch1); + strictEqual(pool.size(), 5); + + // Second cycle - should reuse from pool + const batch2 = []; + for (let i = 0; i < 3; i++) { + batch2.push(pool.acquire('html-string')); + } + strictEqual(pool.size(), 2); // 5 - 3 = 2 remaining + + pool.releaseAll(batch2); + strictEqual(pool.size(), 5); // 2 + 3 = 5 + }); + + it('should work correctly with default maxSize', () => { + const pool = new NodePool(); // Default maxSize = 1000 + + // Create and release many nodes + const nodes = []; + for (let i = 0; i < 100; i++) { + nodes.push(pool.acquire('text')); + } + + pool.releaseAll(nodes); + strictEqual(pool.size(), 100); + + // All should be reusable + for (let i = 0; i < 100; i++) { + pool.acquire('text'); + } + strictEqual(pool.size(), 0); // All reused + }); +}); diff --git a/packages/astro/test/units/render/queue-rendering.test.js b/packages/astro/test/units/render/queue-rendering.test.js new file mode 100644 index 000000000000..4a6ac17d62f1 --- /dev/null +++ b/packages/astro/test/units/render/queue-rendering.test.js @@ -0,0 +1,263 @@ +import * as assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { buildRenderQueue } from '../../../dist/runtime/server/render/queue/builder.js'; +import { renderQueue } from '../../../dist/runtime/server/render/queue/renderer.js'; +import { NodePool } from '../../../dist/runtime/server/render/queue/pool.js'; + +/** + * Tests for the queue-based rendering engine + * These are unit tests for the core queue building and rendering logic + */ +describe('Queue-based rendering engine', () => { + // Create a minimal SSRResult mock for testing + function createMockResult() { + return { + _metadata: { + hasHydrationScript: false, + rendererSpecificHydrationScripts: new Set(), + hasRenderedHead: false, + renderedScripts: new Set(), + hasDirectives: new Set(), + hasRenderedServerIslandRuntime: false, + headInTree: false, + extraHead: [], + extraStyleHashes: [], + extraScriptHashes: [], + propagators: new Set(), + }, + styles: new Set(), + scripts: new Set(), + links: new Set(), + componentMetadata: new Map(), + cancelled: false, + compressHTML: false, + }; + } + + // Create a NodePool for testing + function createMockPool() { + return new NodePool(1000); + } + + describe('buildRenderQueue()', () => { + it('should handle simple text nodes', async () => { + const result = createMockResult(); + const pool = createMockPool(); + const queue = await buildRenderQueue('Hello, World!', result, pool); + + assert.ok(queue.nodes.length > 0); + assert.equal(queue.nodes[0].type, 'text'); + assert.equal(queue.nodes[0].content, 'Hello, World!'); + }); + + it('should handle numbers', async () => { + const result = createMockResult(); + const pool = createMockPool(); + const queue = await buildRenderQueue(42, result, pool); + + assert.ok(queue.nodes.length > 0); + assert.equal(queue.nodes[0].type, 'text'); + assert.equal(queue.nodes[0].content, '42'); + }); + + it('should handle booleans', async () => { + const result = createMockResult(); + const pool = createMockPool(); + const queue = await buildRenderQueue(true, result, pool); + + assert.ok(queue.nodes.length > 0); + assert.equal(queue.nodes[0].type, 'text'); + assert.equal(queue.nodes[0].content, 'true'); + }); + + it('should handle arrays', async () => { + const result = createMockResult(); + const pool = createMockPool(); + const queue = await buildRenderQueue(['Hello', ' ', 'World'], result, pool); + + assert.equal(queue.nodes.length, 3); + assert.equal(queue.nodes[0].content, 'Hello'); + assert.equal(queue.nodes[1].content, ' '); + assert.equal(queue.nodes[2].content, 'World'); + }); + + it('should handle null and undefined (skip them)', async () => { + const result = createMockResult(); + const nullQueue = await buildRenderQueue(null, result); + const undefinedQueue = await buildRenderQueue(undefined, result); + + assert.equal(nullQueue.nodes.length, 0); + assert.equal(undefinedQueue.nodes.length, 0); + }); + + it('should skip false but render 0', async () => { + const result = createMockResult(); + const pool = createMockPool(); + const falseQueue = await buildRenderQueue(false, result, pool); + const zeroQueue = await buildRenderQueue(0, result, pool); + + assert.equal(falseQueue.nodes.length, 0); + assert.equal(zeroQueue.nodes.length, 1); + assert.equal(zeroQueue.nodes[0].content, '0'); + }); + + it('should handle promises', async () => { + const result = createMockResult(); + const promise = Promise.resolve('Resolved value'); + const pool = createMockPool(); + const queue = await buildRenderQueue(promise, result, pool); + + assert.equal(queue.nodes.length, 1); + assert.equal(queue.nodes[0].content, 'Resolved value'); + }); + + it('should handle nested arrays', async () => { + const result = createMockResult(); + const pool = createMockPool(); + const queue = await buildRenderQueue([['Nested', ' '], 'Array'], result, pool); + + assert.equal(queue.nodes.length, 3); + assert.equal(queue.nodes[0].content, 'Nested'); + assert.equal(queue.nodes[1].content, ' '); + assert.equal(queue.nodes[2].content, 'Array'); + }); + + it('should handle async iterables', async () => { + const result = createMockResult(); + + async function* asyncGen() { + yield 'First'; + yield 'Second'; + yield 'Third'; + } + + const pool = createMockPool(); + const queue = await buildRenderQueue(asyncGen(), result, pool); + + assert.equal(queue.nodes.length, 3); + assert.equal(queue.nodes[0].content, 'First'); + assert.equal(queue.nodes[1].content, 'Second'); + assert.equal(queue.nodes[2].content, 'Third'); + }); + + it('should track parent relationships', async () => { + const result = createMockResult(); + const nestedArray = [['child1', 'child2'], 'sibling']; + const pool = createMockPool(); + const queue = await buildRenderQueue(nestedArray, result, pool); + + // Verify correct node structure + assert.equal(queue.nodes.length, 3); + assert.equal(queue.nodes[0].content, 'child1'); + assert.equal(queue.nodes[1].content, 'child2'); + assert.equal(queue.nodes[2].content, 'sibling'); + }); + + it('should maintain correct rendering order', async () => { + const result = createMockResult(); + const pool = createMockPool(); + const queue = await buildRenderQueue(['A', 'B', 'C'], result, pool); + + assert.equal(queue.nodes[0].content, 'A'); + assert.equal(queue.nodes[1].content, 'B'); + assert.equal(queue.nodes[2].content, 'C'); + }); + + it('should handle sync iterables (Set)', async () => { + const result = createMockResult(); + const set = new Set(['One', 'Two', 'Three']); + const pool = createMockPool(); + const queue = await buildRenderQueue(set, result, pool); + + assert.equal(queue.nodes.length, 3); + // Set iteration order is insertion order + const contents = queue.nodes.map((n) => n.content); + assert.ok(contents.includes('One')); + assert.ok(contents.includes('Two')); + assert.ok(contents.includes('Three')); + }); + }); + + describe('renderQueue()', () => { + it('should render simple text to string', async () => { + const result = createMockResult(); + const pool = createMockPool(); + const queue = await buildRenderQueue('Test content', result, pool); + + let output = ''; + const destination = { + write(chunk) { + output += String(chunk); + }, + }; + + await renderQueue(queue, destination); + assert.ok(output.includes('Test content')); + }); + + it('should render array to concatenated string', async () => { + const result = createMockResult(); + const pool = createMockPool(); + const queue = await buildRenderQueue(['Hello', ' ', 'World'], result, pool); + + let output = ''; + const destination = { + write(chunk) { + output += String(chunk); + }, + }; + + await renderQueue(queue, destination); + assert.equal(output, 'Hello World'); + }); + + it('should escape HTML in text nodes', async () => { + const result = createMockResult(); + const pool = createMockPool(); + const queue = await buildRenderQueue('', result, pool); + + let output = ''; + const destination = { + write(chunk) { + output += String(chunk); + }, + }; + + await renderQueue(queue, destination); + assert.ok(!output.includes('