diff --git a/.changeset/cute-times-flash.md b/.changeset/cute-times-flash.md new file mode 100644 index 000000000000..84fe75532e04 --- /dev/null +++ b/.changeset/cute-times-flash.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +support nested components in diff --git a/packages/svelte/src/internal/client/dom/blocks/svelte-head.js b/packages/svelte/src/internal/client/dom/blocks/svelte-head.js index 7c7eed24f71a..773a2acc8d29 100644 --- a/packages/svelte/src/internal/client/dom/blocks/svelte-head.js +++ b/packages/svelte/src/internal/client/dom/blocks/svelte-head.js @@ -49,7 +49,30 @@ export function head(hash, render_fn) { } try { + // Track nodes added to head before render + const head_children_before = Array.from(document.head.children); + const head_child_count_before = head_children_before.length; + block(() => render_fn(anchor), HEAD_EFFECT); + + // After rendering, check if non-head elements were added and move them to body + const head_child_count_after = document.head.children.length; + if (head_child_count_after > head_children_before.length) { + // Elements were added to head, check if any are non-head elements + const new_children = Array.from(document.head.children).slice(head_child_count_before); + for (const child of new_children) { + // Move non-head-specific elements to body + if (child.nodeType === 1) { + // ELEMENT_NODE + const tag = child.tagName.toLowerCase(); + // Only keep head-specific elements (script, meta, link, style, title, base, noscript) + // Move div, span, and other body elements to the body + if (!['script', 'meta', 'link', 'style', 'title', 'base', 'noscript'].includes(tag)) { + document.body.appendChild(child); + } + } + } + } } finally { if (was_hydrating) { set_hydrating(true); diff --git a/packages/svelte/src/internal/server/index.js b/packages/svelte/src/internal/server/index.js index c0dbdbda14f6..d6291ddfe4d7 100644 --- a/packages/svelte/src/internal/server/index.js +++ b/packages/svelte/src/internal/server/index.js @@ -70,10 +70,30 @@ export function render(component, options = {}) { * @returns {void} */ export function head(hash, renderer, fn) { - renderer.head((renderer) => { - renderer.push(``); - renderer.child(fn); - renderer.push(EMPTY_COMMENT); + renderer.head((parent_renderer) => { + parent_renderer.push(``); + + // Create a capture renderer to collect all output from the function + const capture_renderer = new Renderer(parent_renderer.global, parent_renderer); + const result = fn(capture_renderer); + + if (result instanceof Promise) { + return result.then(() => { + // Collect content and only push head content + const content = capture_renderer.collect_sync(); + if (content.head) { + parent_renderer.push(content.head); + } + }); + } else { + // Collect content and only push head content + const content = capture_renderer.collect_sync(); + if (content.head) { + parent_renderer.push(content.head); + } + } + + parent_renderer.push(EMPTY_COMMENT); }); } diff --git a/packages/svelte/src/internal/server/renderer.js b/packages/svelte/src/internal/server/renderer.js index 0cfb1a7a9351..656a00ecaf7e 100644 --- a/packages/svelte/src/internal/server/renderer.js +++ b/packages/svelte/src/internal/server/renderer.js @@ -371,6 +371,14 @@ export class Renderer { return this.#out.length; } + /** + * Collect all content from this renderer (both head and body) synchronously. + * @returns {AccumulatedContent} + */ + collect_sync() { + return this.#collect_content(); + } + /** * Only available on the server and when compiling with the `server` option. * Takes a component and returns an object with `body` and `head` properties on it, which you can use to populate the HTML when server-rendering your app. diff --git a/packages/svelte/tests/runtime-browser/samples/head-nested-body-separation/Child.svelte b/packages/svelte/tests/runtime-browser/samples/head-nested-body-separation/Child.svelte new file mode 100644 index 000000000000..7c306a14a23e --- /dev/null +++ b/packages/svelte/tests/runtime-browser/samples/head-nested-body-separation/Child.svelte @@ -0,0 +1,5 @@ + + + + +

Child body

diff --git a/packages/svelte/tests/runtime-browser/samples/head-nested-body-separation/_config.js b/packages/svelte/tests/runtime-browser/samples/head-nested-body-separation/_config.js new file mode 100644 index 000000000000..30df87a8fd8c --- /dev/null +++ b/packages/svelte/tests/runtime-browser/samples/head-nested-body-separation/_config.js @@ -0,0 +1,24 @@ +import { test } from '../../assert'; + +export default test({ + async test({ assert, target }) { + // Test 1: Verify body content appears in body, not head + const mainContent = target.querySelector('main'); + assert.ok(mainContent, 'Main element should exist in body'); + assert.equal(mainContent?.textContent, 'Main content'); + + // Test 2: Verify head contains meta tag (head-specific) + const metaTag = document.head.querySelector('meta[name="child-data"]'); + assert.ok(metaTag, 'Meta tag should be in head'); + assert.equal(metaTag?.getAttribute('content'), 'value'); + + // Test 3: Verify title is in head + const titleTag = document.head.querySelector('title'); + assert.ok(titleTag, 'Title should be in head'); + assert.equal(titleTag?.textContent, 'CSR Test'); + + // Test 4: Verify body elements are NOT in head + const pInHead = document.head.querySelector('p'); + assert.equal(pInHead, null, 'Paragraph element should NOT be in head'); + } +}); diff --git a/packages/svelte/tests/runtime-browser/samples/head-nested-body-separation/main.svelte b/packages/svelte/tests/runtime-browser/samples/head-nested-body-separation/main.svelte new file mode 100644 index 000000000000..c6ae1553694a --- /dev/null +++ b/packages/svelte/tests/runtime-browser/samples/head-nested-body-separation/main.svelte @@ -0,0 +1,10 @@ + + + + CSR Test + + + +
Main content
diff --git a/packages/svelte/tests/runtime-browser/test.ts b/packages/svelte/tests/runtime-browser/test.ts deleted file mode 100644 index 63e601b115fa..000000000000 --- a/packages/svelte/tests/runtime-browser/test.ts +++ /dev/null @@ -1,233 +0,0 @@ -import { chromium } from '@playwright/test'; -import { build } from 'esbuild'; -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import { compile } from 'svelte/compiler'; -import { afterAll, assert, beforeAll, describe } from 'vitest'; -import { suite, suite_with_variants } from '../suite'; -import { write, fragments } from '../helpers'; -import type { Warning } from '#compiler'; - -const assert_file = path.resolve(__dirname, 'assert.js'); - -let browser: import('@playwright/test').Browser; - -beforeAll(async () => { - browser = await chromium.launch(); -}, 20000); - -afterAll(async () => { - if (browser) await browser.close(); -}); - -const { run: run_browser_tests } = suite_with_variants< - ReturnType, - 'dom' | 'hydrate', - void ->( - ['dom', 'hydrate'], - (variant, config) => { - if (variant === 'hydrate') { - if (config.mode && !config.mode.includes('hydrate')) return 'no-test'; - if (config.skip_mode?.includes('hydrate')) return true; - } - - return false; - }, - () => {}, - async (config, test_dir, variant) => { - await run_test(test_dir, config, variant === 'hydrate'); - } -); - -describe.concurrent( - 'runtime-browser', - () => run_browser_tests(__dirname), - // Browser tests are brittle and slow on CI - { timeout: 20000, retry: process.env.CI ? 1 : 0 } -); - -const { run: run_ce_tests } = suite>( - async (config, test_dir) => { - await run_test(test_dir, config, false); - } -); - -describe.concurrent( - 'custom-elements', - () => run_ce_tests(__dirname, 'custom-elements-samples'), - // Browser tests are brittle and slow on CI - { timeout: 20000, retry: process.env.CI ? 1 : 0 } -); - -async function run_test( - test_dir: string, - config: ReturnType, - hydrate: boolean -) { - const warnings: any[] = []; - - const build_result = await build({ - entryPoints: [`${__dirname}/driver.js`], - write: false, - define: { - __HYDRATE__: String(hydrate), - __CE_TEST__: String(test_dir.includes('custom-elements-samples')) - }, - alias: { - __MAIN_DOT_SVELTE__: path.resolve(test_dir, 'main.svelte'), - __CONFIG__: path.resolve(test_dir, '_config.js'), - 'assert.js': assert_file - }, - conditions: ['browser', 'production'], - plugins: [ - { - name: 'testing-runtime-browser', - setup(build) { - build.onLoad({ filter: /\.svelte$/ }, (args) => { - const compiled = compile(fs.readFileSync(args.path, 'utf-8').replace(/\r/g, ''), { - generate: 'client', - fragments, - ...config.compileOptions, - immutable: config.immutable, - customElement: test_dir.includes('custom-elements-samples'), - accessors: 'accessors' in config ? config.accessors : true - }); - - write(`${test_dir}/_output/client/${path.basename(args.path)}.js`, compiled.js.code); - - compiled.warnings.forEach((warning) => { - if (warning.code === 'options_deprecated_accessors') return; - warnings.push(warning); - }); - - if (compiled.css !== null) { - compiled.js.code += `document.head.innerHTML += \`\``; - write( - `${test_dir}/_output/client/${path.basename(args.path)}.css`, - compiled.css.code - ); - } - - return { - contents: compiled.js.code, - loader: 'js' - }; - }); - } - } - ], - bundle: true, - format: 'iife', - globalName: 'test' - }); - - let build_result_ssr; - if (hydrate) { - const ssr_entry = path.resolve(__dirname, '../../src/index-server.js'); - - build_result_ssr = await build({ - entryPoints: [`${__dirname}/driver-ssr.js`], - write: false, - alias: { - __MAIN_DOT_SVELTE__: path.resolve(test_dir, 'main.svelte'), - __CONFIG__: path.resolve(test_dir, '_config.js') - }, - conditions: ['browser', 'production'], - plugins: [ - { - name: 'testing-runtime-browser-ssr', - setup(build) { - // When running the server version of the Svelte files, - // we also want to use the server export of the Svelte package - build.onResolve({ filter: /./ }, (args) => { - if (args.path === 'svelte') { - return { path: ssr_entry }; - } - }); - - build.onLoad({ filter: /\.svelte$/ }, (args) => { - const compiled = compile(fs.readFileSync(args.path, 'utf-8').replace(/\r/g, ''), { - generate: 'server', - ...config.compileOptions, - immutable: config.immutable, - customElement: test_dir.includes('custom-elements-samples'), - accessors: 'accessors' in config ? config.accessors : true - }); - - return { - contents: compiled.js.code, - loader: 'js' - }; - }); - } - } - ], - bundle: true, - format: 'iife', - globalName: 'test_ssr' - }); - } - - function assert_warnings() { - if (config.warnings) { - assert.deepStrictEqual( - warnings.map( - (w) => - ({ - code: w.code, - message: w.message, - start: w.start, - end: w.end - }) as Warning - ), - config.warnings - ); - } else if (warnings.length) { - /* eslint-disable no-unsafe-finally */ - console.warn(warnings); - throw new Error('Received unexpected warnings'); - } - } - - assert_warnings(); - - try { - const page = await browser.newPage(); - page.on('console', (message) => { - let method = message.type(); - if (method === 'warning') method = 'warn'; - // @ts-ignore - console[method](message.text()); - }); - - if (build_result_ssr) { - const result: any = await page.evaluate( - build_result_ssr.outputFiles[0].text + '; test_ssr.default()' - ); - await page.setContent('' + result.head + '
' + result.html + '
'); - } else { - await page.setContent('
'); - } - - // uncomment to see what was generated - // fs.writeFileSync(`${test_dir}/_actual.js`, build_result.outputFiles[0].text); - const test_result = await page.evaluate( - build_result.outputFiles[0].text + ";test.default(document.querySelector('main'))" - ); - - if (test_result) console.log(test_result); - await page.close(); - } catch (err: any) { - pretty_print_browser_assertion(err.message); - throw err; - } -} - -function pretty_print_browser_assertion(message: string) { - const match = /Error: Expected "(.+)" to equal "(.+)"/.exec(message); - - if (match) { - assert.equal(match[1], match[2]); - } -} diff --git a/packages/svelte/tests/server-side-rendering/samples/head-nested-body-separation/Child.svelte b/packages/svelte/tests/server-side-rendering/samples/head-nested-body-separation/Child.svelte new file mode 100644 index 000000000000..a000b1610597 --- /dev/null +++ b/packages/svelte/tests/server-side-rendering/samples/head-nested-body-separation/Child.svelte @@ -0,0 +1,5 @@ + + + + +
Child body
diff --git a/packages/svelte/tests/server-side-rendering/samples/head-nested-body-separation/_config.js b/packages/svelte/tests/server-side-rendering/samples/head-nested-body-separation/_config.js new file mode 100644 index 000000000000..861ddf243b87 --- /dev/null +++ b/packages/svelte/tests/server-side-rendering/samples/head-nested-body-separation/_config.js @@ -0,0 +1,5 @@ +import { test } from '../../test'; + +export default test({ + mode: ['async', 'sync'] +}); diff --git a/packages/svelte/tests/server-side-rendering/samples/head-nested-body-separation/_expected.html b/packages/svelte/tests/server-side-rendering/samples/head-nested-body-separation/_expected.html new file mode 100644 index 000000000000..9bc13602286e --- /dev/null +++ b/packages/svelte/tests/server-side-rendering/samples/head-nested-body-separation/_expected.html @@ -0,0 +1 @@ +
Main content
\ No newline at end of file diff --git a/packages/svelte/tests/server-side-rendering/samples/head-nested-body-separation/_expected_head.html b/packages/svelte/tests/server-side-rendering/samples/head-nested-body-separation/_expected_head.html new file mode 100644 index 000000000000..d5d2f1243d9f --- /dev/null +++ b/packages/svelte/tests/server-side-rendering/samples/head-nested-body-separation/_expected_head.html @@ -0,0 +1 @@ +
Child body
Parent Title \ No newline at end of file diff --git a/packages/svelte/tests/server-side-rendering/samples/head-nested-body-separation/main.svelte b/packages/svelte/tests/server-side-rendering/samples/head-nested-body-separation/main.svelte new file mode 100644 index 000000000000..c4b48463a015 --- /dev/null +++ b/packages/svelte/tests/server-side-rendering/samples/head-nested-body-separation/main.svelte @@ -0,0 +1,10 @@ + + + + Parent Title + + + +
Main content