diff --git a/.changeset/lemon-paws-work.md b/.changeset/lemon-paws-work.md new file mode 100644 index 000000000000..0cba4622673b --- /dev/null +++ b/.changeset/lemon-paws-work.md @@ -0,0 +1,5 @@ +--- +'svelte': minor +--- + +feat: add `` element diff --git a/documentation/docs/05-special-elements/04-svelte-html.md b/documentation/docs/05-special-elements/04-svelte-html.md new file mode 100644 index 000000000000..9708a9f0ec77 --- /dev/null +++ b/documentation/docs/05-special-elements/04-svelte-html.md @@ -0,0 +1,22 @@ +--- +title: +--- + +```svelte + +``` + +This element allows you to add attributes and event listeners to the `` root (i.e. `document.documentElement`). This is useful for attributes such as `lang` which influence how the browser interprets the content. + +```svelte + + + + +``` + +> [!NOTE] If you use SvelteKit version 2.13 or higher (and have `%svelte.htmlAttributes%` on the `` tag in `app.html`), the attributes will automatically be server rendered and hydrated correctly. If you're using a custom server rendering setup, you can retrieve the server-rendered attributes string via `htmlAttributes` from the `render` method response and inject it into your HTML manually. + +This element may only appear the top level of your component and must never be inside a block or element. diff --git a/documentation/docs/98-reference/.generated/compile-errors.md b/documentation/docs/98-reference/.generated/compile-errors.md index 298363f78d38..715844bdd64a 100644 --- a/documentation/docs/98-reference/.generated/compile-errors.md +++ b/documentation/docs/98-reference/.generated/compile-errors.md @@ -864,6 +864,12 @@ Invalid component definition — must be an `{expression}` `` cannot have attributes nor directives ``` +### svelte_html_illegal_attribute + +``` +`` can only have regular attributes +``` + ### svelte_meta_duplicate ``` diff --git a/documentation/docs/98-reference/.generated/shared-warnings.md b/documentation/docs/98-reference/.generated/shared-warnings.md index f449a4031ebf..06e813258769 100644 --- a/documentation/docs/98-reference/.generated/shared-warnings.md +++ b/documentation/docs/98-reference/.generated/shared-warnings.md @@ -26,3 +26,11 @@ The following properties cannot be cloned with `$state.snapshot` — the return const object = $state({ property: 'this is cloneable', window }) const snapshot = $state.snapshot(object); ``` + +### svelte_html_duplicate_attribute + +``` +Duplicate attribute '%name%' across multiple `` blocks, the latest value will be used. +``` + +This warning appears when you have multiple `` blocks across several files, and they set the same attribute. In that case, the latest value wins. On the server and on the client for static attributes, that's the last occurence of the attribute. On the client for dynamic attributes that's the value which was updated last across all `` blocks. diff --git a/packages/svelte/elements.d.ts b/packages/svelte/elements.d.ts index 8800b65172dc..2c055a80c958 100644 --- a/packages/svelte/elements.d.ts +++ b/packages/svelte/elements.d.ts @@ -2024,6 +2024,7 @@ export interface SvelteHTMLElements { 'svelte:window': SvelteWindowAttributes; 'svelte:document': SvelteDocumentAttributes; 'svelte:body': HTMLAttributes; + 'svelte:html': HTMLAttributes; 'svelte:fragment': { slot?: string }; 'svelte:options': { customElement?: diff --git a/packages/svelte/messages/compile-errors/template.md b/packages/svelte/messages/compile-errors/template.md index 02961b61fccc..a28a33fc4eec 100644 --- a/packages/svelte/messages/compile-errors/template.md +++ b/packages/svelte/messages/compile-errors/template.md @@ -322,6 +322,10 @@ HTML restricts where certain elements can appear. In case of a violation the bro > `` cannot have attributes nor directives +## svelte_html_illegal_attribute + +> `` can only have regular attributes + ## svelte_meta_duplicate > A component can only have one `<%name%>` element diff --git a/packages/svelte/messages/shared-warnings/warnings.md b/packages/svelte/messages/shared-warnings/warnings.md index fb27867f9bf3..e801f8cfa959 100644 --- a/packages/svelte/messages/shared-warnings/warnings.md +++ b/packages/svelte/messages/shared-warnings/warnings.md @@ -18,3 +18,9 @@ Elements such as `` cannot have content, any children passed to these ele const object = $state({ property: 'this is cloneable', window }) const snapshot = $state.snapshot(object); ``` + +## svelte_html_duplicate_attribute + +> Duplicate attribute '%name%' across multiple `` blocks, the latest value will be used. + +This warning appears when you have multiple `` blocks across several files, and they set the same attribute. In that case, the latest value wins. On the server and on the client for static attributes, that's the last occurence of the attribute. On the client for dynamic attributes that's the value which was updated last across all `` blocks. diff --git a/packages/svelte/src/compiler/errors.js b/packages/svelte/src/compiler/errors.js index 9ea13e811e5f..e7e0c549e46c 100644 --- a/packages/svelte/src/compiler/errors.js +++ b/packages/svelte/src/compiler/errors.js @@ -1355,6 +1355,15 @@ export function svelte_head_illegal_attribute(node) { e(node, "svelte_head_illegal_attribute", `\`\` cannot have attributes nor directives\nhttps://svelte.dev/e/svelte_head_illegal_attribute`); } +/** + * `` can only have regular attributes + * @param {null | number | NodeLike} node + * @returns {never} + */ +export function svelte_html_illegal_attribute(node) { + e(node, "svelte_html_illegal_attribute", `\`\` can only have regular attributes\nhttps://svelte.dev/e/svelte_html_illegal_attribute`); +} + /** * A component can only have one `<%name%>` element * @param {null | number | NodeLike} node diff --git a/packages/svelte/src/compiler/phases/1-parse/state/element.js b/packages/svelte/src/compiler/phases/1-parse/state/element.js index 66946a8f8d22..eb5d698a8e58 100644 --- a/packages/svelte/src/compiler/phases/1-parse/state/element.js +++ b/packages/svelte/src/compiler/phases/1-parse/state/element.js @@ -34,6 +34,7 @@ const root_only_meta_tags = new Map([ ['svelte:head', 'SvelteHead'], ['svelte:options', 'SvelteOptions'], ['svelte:window', 'SvelteWindow'], + ['svelte:html', 'SvelteHTML'], ['svelte:document', 'SvelteDocument'], ['svelte:body', 'SvelteBody'] ]); diff --git a/packages/svelte/src/compiler/phases/2-analyze/index.js b/packages/svelte/src/compiler/phases/2-analyze/index.js index 042e88fa2f83..3eeecbb27ecb 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/index.js +++ b/packages/svelte/src/compiler/phases/2-analyze/index.js @@ -59,6 +59,7 @@ import { SvelteDocument } from './visitors/SvelteDocument.js'; import { SvelteElement } from './visitors/SvelteElement.js'; import { SvelteFragment } from './visitors/SvelteFragment.js'; import { SvelteHead } from './visitors/SvelteHead.js'; +import { SvelteHTML } from './visitors/SvelteHTML.js'; import { SvelteSelf } from './visitors/SvelteSelf.js'; import { SvelteWindow } from './visitors/SvelteWindow.js'; import { SvelteBoundary } from './visitors/SvelteBoundary.js'; @@ -172,6 +173,7 @@ const visitors = { SvelteElement, SvelteFragment, SvelteHead, + SvelteHTML, SvelteSelf, SvelteWindow, SvelteBoundary, diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/SvelteHTML.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/SvelteHTML.js new file mode 100644 index 000000000000..195528491e89 --- /dev/null +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/SvelteHTML.js @@ -0,0 +1,21 @@ +/** @import { AST } from '#compiler' */ +/** @import { Context } from '../types' */ +import * as e from '../../../errors.js'; + +/** + * @param {AST.SvelteHTML} node + * @param {Context} context + */ +export function SvelteHTML(node, context) { + for (const attribute of node.attributes) { + if (attribute.type !== 'Attribute') { + e.svelte_html_illegal_attribute(attribute); + } + } + + if (node.fragment.nodes.length > 0) { + e.svelte_meta_invalid_content(node, node.name); + } + + context.next(); +} diff --git a/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js b/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js index ccbdcea4cc56..e230a2878d25 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js @@ -50,6 +50,7 @@ import { SvelteElement } from './visitors/SvelteElement.js'; import { SvelteFragment } from './visitors/SvelteFragment.js'; import { SvelteBoundary } from './visitors/SvelteBoundary.js'; import { SvelteHead } from './visitors/SvelteHead.js'; +import { SvelteHTML } from './visitors/SvelteHTML.js'; import { SvelteSelf } from './visitors/SvelteSelf.js'; import { SvelteWindow } from './visitors/SvelteWindow.js'; import { TitleElement } from './visitors/TitleElement.js'; @@ -125,6 +126,7 @@ const visitors = { SvelteFragment, SvelteBoundary, SvelteHead, + SvelteHTML, SvelteSelf, SvelteWindow, TitleElement, diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteHTML.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteHTML.js new file mode 100644 index 000000000000..3f239b930e90 --- /dev/null +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteHTML.js @@ -0,0 +1,44 @@ +/** @import { ExpressionStatement, Property } from 'estree' */ +/** @import { AST } from '#compiler' */ +/** @import { ComponentContext } from '../types' */ +import { normalize_attribute } from '../../../../../utils.js'; +import { is_event_attribute } from '../../../../utils/ast.js'; +import * as b from '../../../../utils/builders.js'; +import { build_attribute_value } from './shared/element.js'; +import { visit_event_attribute } from './shared/events.js'; + +/** + * @param {AST.SvelteHTML} element + * @param {ComponentContext} context + */ +export function SvelteHTML(element, context) { + const event_context = { + ...context, + state: { ...context.state, node: b.id('$.document.documentElement') } + }; + /** @type {Property[]} */ + const attributes = []; + + for (const attribute of element.attributes) { + if (attribute.type === 'Attribute') { + if (is_event_attribute(attribute)) { + visit_event_attribute(attribute, event_context); + } else { + const name = normalize_attribute(attribute.name); + const { value } = build_attribute_value(attribute.value, context); + + attributes.push(b.init(name, value)); + + if (context.state.options.dev) { + context.state.init.push( + b.stmt(b.call('$.validate_svelte_html_attribute', b.literal(name))) + ); + } + } + } + } + + if (attributes.length > 0) { + context.state.init.push(b.stmt(b.call('$.svelte_html', b.arrow([], b.object(attributes))))); + } +} diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/events.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/events.js index f23f7548ece1..c2671b645f4c 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/events.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/events.js @@ -70,7 +70,12 @@ export function visit_event_attribute(node, context) { const type = /** @type {AST.SvelteNode} */ (context.path.at(-1)).type; - if (type === 'SvelteDocument' || type === 'SvelteWindow' || type === 'SvelteBody') { + if ( + type === 'SvelteDocument' || + type === 'SvelteWindow' || + type === 'SvelteBody' || + type === 'SvelteHTML' + ) { // These nodes are above the component tree, and its events should run parent first context.state.init.push(statement); } else { diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/special_element.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/special_element.js index 558bc4fee7b4..eff5bf4869b4 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/special_element.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/special_element.js @@ -4,7 +4,7 @@ import * as b from '../../../../../utils/builders.js'; /** - * + * Puts all event listeners onto the given element * @param {AST.SvelteBody | AST.SvelteDocument | AST.SvelteWindow} node * @param {string} id * @param {ComponentContext} context diff --git a/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js b/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js index 982b75e12f53..61bc80d6a7af 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js @@ -34,6 +34,7 @@ import { SvelteComponent } from './visitors/SvelteComponent.js'; import { SvelteElement } from './visitors/SvelteElement.js'; import { SvelteFragment } from './visitors/SvelteFragment.js'; import { SvelteHead } from './visitors/SvelteHead.js'; +import { SvelteHTML } from './visitors/SvelteHTML.js'; import { SvelteSelf } from './visitors/SvelteSelf.js'; import { TitleElement } from './visitors/TitleElement.js'; import { UpdateExpression } from './visitors/UpdateExpression.js'; @@ -75,6 +76,7 @@ const template_visitors = { SvelteElement, SvelteFragment, SvelteHead, + SvelteHTML, SvelteSelf, TitleElement, SvelteBoundary diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/SvelteHTML.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/SvelteHTML.js new file mode 100644 index 000000000000..78750712d80a --- /dev/null +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/SvelteHTML.js @@ -0,0 +1,28 @@ +/** @import { Property } from 'estree' */ +/** @import { AST } from '#compiler' */ +/** @import { ComponentContext } from '../types.js' */ +import { normalize_attribute } from '../../../../../utils.js'; +import { is_event_attribute } from '../../../../utils/ast.js'; +import * as b from '../../../../utils/builders.js'; +import { build_attribute_value } from './shared/utils.js'; + +/** + * @param {AST.SvelteHTML} element + * @param {ComponentContext} context + */ +export function SvelteHTML(element, context) { + /** @type {Property[]} */ + const attributes = []; + + for (const attribute of element.attributes) { + if (attribute.type === 'Attribute' && !is_event_attribute(attribute)) { + const name = normalize_attribute(attribute.name); + const value = build_attribute_value(attribute.value, context); + attributes.push(b.init(name, value)); + } + } + + context.state.template.push( + b.stmt(b.call('$.svelte_html', b.id('$$payload'), b.object(attributes))) + ); +} diff --git a/packages/svelte/src/compiler/phases/3-transform/utils.js b/packages/svelte/src/compiler/phases/3-transform/utils.js index b53a3110bd22..782f78529f59 100644 --- a/packages/svelte/src/compiler/phases/3-transform/utils.js +++ b/packages/svelte/src/compiler/phases/3-transform/utils.js @@ -172,6 +172,7 @@ export function clean_nodes( node.type === 'ConstTag' || node.type === 'DebugTag' || node.type === 'SvelteBody' || + node.type === 'SvelteHTML' || node.type === 'SvelteWindow' || node.type === 'SvelteDocument' || node.type === 'SvelteHead' || diff --git a/packages/svelte/src/compiler/types/template.d.ts b/packages/svelte/src/compiler/types/template.d.ts index 97a25df4a758..e0c4f9821cdb 100644 --- a/packages/svelte/src/compiler/types/template.d.ts +++ b/packages/svelte/src/compiler/types/template.d.ts @@ -315,6 +315,11 @@ export namespace AST { }; } + export interface SvelteHTML extends BaseElement { + type: 'SvelteHTML'; + name: 'svelte:html'; + } + export interface SvelteBody extends BaseElement { type: 'SvelteBody'; name: 'svelte:body'; @@ -525,6 +530,7 @@ export namespace AST { | AST.TitleElement | AST.SlotElement | AST.RegularElement + | AST.SvelteHTML | AST.SvelteBody | AST.SvelteBoundary | AST.SvelteComponent diff --git a/packages/svelte/src/internal/client/dom/blocks/svelte-html.js b/packages/svelte/src/internal/client/dom/blocks/svelte-html.js new file mode 100644 index 000000000000..a96bd41b5329 --- /dev/null +++ b/packages/svelte/src/internal/client/dom/blocks/svelte-html.js @@ -0,0 +1,73 @@ +import { effect, render_effect, teardown } from '../../reactivity/effects.js'; +import { untrack } from '../../runtime.js'; +import { set_attribute } from '../elements/attributes.js'; +import { set_class } from '../elements/class.js'; +import { hydrating } from '../hydration.js'; + +/** + * @param {() => Record} get_attributes + * @returns {void} + */ +export function svelte_html(get_attributes) { + const node = document.documentElement; + const self = {}; + + /** @type {Record>} to check who set the last value of each attribute */ + // @ts-expect-error + const current_setters = (node.__attributes_setters ??= {}); + + /** @type {Record} Does _not_ contain event listeners, those are handled separately */ + let attributes; + + render_effect(() => { + attributes = get_attributes(); + + for (const name in attributes) { + const current = (current_setters[name] ??= []); + const index = current.findIndex((c) => c.owner === self); + const old = index === -1 ? null : current.splice(index, 1)[0].value; + + let value = attributes[name]; + current.push({ owner: self, value }); + + const set = () => { + if (name === 'class') { + // Avoid unrelated attribute changes from triggering class changes + if (old !== value) { + set_class(node, current_setters[name].map((e) => e.value).join(' ')); + } + } else { + set_attribute(node, name, value); + } + }; + + // Defer hydration on initial render during hydration: If there are attribute duplicates, the last value + // wins, so we wait until all values have been set to see if we're actually the last one that sets the value. + if (hydrating) { + effect(() => { + if (current[current.length - 1].owner === self) { + set(); + } + }); + } else { + set(); + } + } + }); + + teardown(() => { + for (const name in attributes) { + const old = current_setters[name]; + current_setters[name] = old.filter((o) => o.owner !== self); + const current = current_setters[name]; + + if (name === 'class') { + set_class(node, current.map((c) => c.value).join(' ')); + + // If this was the last one setting this attribute, revert to the previous value + } else if (old[old.length - 1].owner === self) { + set_attribute(node, name, current[current.length - 1]?.value); + } + } + }); +} diff --git a/packages/svelte/src/internal/client/dom/elements/events.js b/packages/svelte/src/internal/client/dom/elements/events.js index f2038f96ada3..e6309d990296 100644 --- a/packages/svelte/src/internal/client/dom/elements/events.js +++ b/packages/svelte/src/internal/client/dom/elements/events.js @@ -117,8 +117,14 @@ export function event(event_name, dom, handler, capture, passive) { var options = { capture, passive }; var target_handler = create_event(event_name, dom, handler, options); - // @ts-ignore - if (dom === document.body || dom === window || dom === document) { + if ( + // @ts-ignore + dom === window || + // @ts-ignore + dom === document || + dom === document.body || + dom === document.documentElement + ) { teardown(() => { dom.removeEventListener(event_name, target_handler, options); }); diff --git a/packages/svelte/src/internal/client/index.js b/packages/svelte/src/internal/client/index.js index f22c33babc52..0580a0928139 100644 --- a/packages/svelte/src/internal/client/index.js +++ b/packages/svelte/src/internal/client/index.js @@ -25,6 +25,7 @@ export { snippet, wrap_snippet } from './dom/blocks/snippet.js'; export { component } from './dom/blocks/svelte-component.js'; export { element } from './dom/blocks/svelte-element.js'; export { head } from './dom/blocks/svelte-head.js'; +export { svelte_html } from './dom/blocks/svelte-html.js'; export { append_styles } from './dom/css.js'; export { action } from './dom/elements/actions.js'; export { @@ -150,7 +151,12 @@ export { setContext, hasContext } from './runtime.js'; -export { validate_binding, validate_each_keys, validate_prop_bindings } from './validate.js'; +export { + validate_binding, + validate_each_keys, + validate_prop_bindings, + validate_svelte_html_attribute +} from './validate.js'; export { raf } from './timing.js'; export { proxy } from './proxy.js'; export { create_custom_element } from './dom/elements/custom-element.js'; diff --git a/packages/svelte/src/internal/client/validate.js b/packages/svelte/src/internal/client/validate.js index 951feee33bdf..88b6e2abb8c8 100644 --- a/packages/svelte/src/internal/client/validate.js +++ b/packages/svelte/src/internal/client/validate.js @@ -2,9 +2,10 @@ import { dev_current_component_function } from './runtime.js'; import { get_descriptor, is_array } from '../shared/utils.js'; import * as e from './errors.js'; import { FILENAME } from '../../constants.js'; -import { render_effect } from './reactivity/effects.js'; +import { render_effect, teardown } from './reactivity/effects.js'; import * as w from './warnings.js'; import { capture_store_binding } from './reactivity/store.js'; +import { svelte_html_duplicate_attribute } from '../shared/warnings.js'; /** * @param {() => any} collection @@ -104,3 +105,27 @@ export function validate_binding(binding, get_object, get_property, line, column } }); } + +let svelte_html_attributes = new Map(); + +/** + * @param {string} name + */ +export function validate_svelte_html_attribute(name) { + const count = svelte_html_attributes.get(name) || 0; + + if (count > 0) { + svelte_html_duplicate_attribute(name); + } + + svelte_html_attributes.set(name, count + 1); + + teardown(() => { + const count = svelte_html_attributes.get(name) || 1; + if (count === 1) { + svelte_html_attributes.delete(name); + } else { + svelte_html_attributes.set(name, count - 1); + } + }); +} diff --git a/packages/svelte/src/internal/server/blocks/svelte-html.js b/packages/svelte/src/internal/server/blocks/svelte-html.js new file mode 100644 index 000000000000..d4b2506dfe5a --- /dev/null +++ b/packages/svelte/src/internal/server/blocks/svelte-html.js @@ -0,0 +1,24 @@ +/** @import { Payload } from '#server' */ + +import { svelte_html_duplicate_attribute } from '../../shared/warnings.js'; + +/** + * @param {Payload} payload + * @param {Record} attributes + */ +export function svelte_html(payload, attributes) { + for (const name in attributes) { + let value = attributes[name]; + + if (payload.htmlAttributes.has(name)) { + if (name === 'class') { + // Don't bother deduplicating class names, the browser handles it just fine + value = `${payload.htmlAttributes.get(name)} ${value}`.trim(); + } else { + svelte_html_duplicate_attribute(name); + } + } + + payload.htmlAttributes.set(name, value); + } +} diff --git a/packages/svelte/src/internal/server/index.js b/packages/svelte/src/internal/server/index.js index b8371b7e008f..d9fc7d757345 100644 --- a/packages/svelte/src/internal/server/index.js +++ b/packages/svelte/src/internal/server/index.js @@ -28,9 +28,10 @@ const INVALID_ATTR_NAME_CHAR_REGEX = * @param {Payload} to_copy * @returns {Payload} */ -export function copy_payload({ out, css, head }) { +export function copy_payload({ out, htmlAttributes, css, head }) { return { out, + htmlAttributes: new Map(htmlAttributes), css: new Set(css), head: { title: head.title, @@ -93,7 +94,12 @@ export let on_destroy = []; */ export function render(component, options = {}) { /** @type {Payload} */ - const payload = { out: '', css: new Set(), head: { title: '', out: '' } }; + const payload = { + out: '', + htmlAttributes: new Map(), + css: new Set(), + head: { title: '', out: '' } + }; const prev_on_destroy = on_destroy; on_destroy = []; @@ -135,7 +141,11 @@ export function render(component, options = {}) { return { head, html: payload.out, - body: payload.out + body: payload.out, + htmlAttributes: [...payload.htmlAttributes] + .map(([name, value]) => attr(name, value, is_boolean_attribute(name))) + .join('') + .trim() }; } @@ -526,6 +536,8 @@ export { attr }; export { html } from './blocks/html.js'; +export { svelte_html } from './blocks/svelte-html.js'; + export { push, pop } from './context.js'; export { push_element, pop_element } from './dev.js'; diff --git a/packages/svelte/src/internal/server/types.d.ts b/packages/svelte/src/internal/server/types.d.ts index e6c235147b5f..c17b6f2d80e7 100644 --- a/packages/svelte/src/internal/server/types.d.ts +++ b/packages/svelte/src/internal/server/types.d.ts @@ -13,6 +13,7 @@ export interface Component { export interface Payload { out: string; + htmlAttributes: Map; css: Set<{ hash: string; code: string }>; head: { title: string; @@ -27,4 +28,6 @@ export interface RenderOutput { html: string; /** HTML that goes somewhere into the `` */ body: string; + /** Attributes that go onto the `` */ + htmlAttributes: string; } diff --git a/packages/svelte/src/internal/shared/warnings.js b/packages/svelte/src/internal/shared/warnings.js index 83c58c9b14a2..38c2bc634c31 100644 --- a/packages/svelte/src/internal/shared/warnings.js +++ b/packages/svelte/src/internal/shared/warnings.js @@ -33,4 +33,16 @@ ${properties}` } else { console.warn(`https://svelte.dev/e/state_snapshot_uncloneable`); } +} + +/** + * Duplicate attribute '%name%' across multiple `` blocks, the latest value will be used. + * @param {string} name + */ +export function svelte_html_duplicate_attribute(name) { + if (DEV) { + console.warn(`%c[svelte] svelte_html_duplicate_attribute\n%cDuplicate attribute '${name}' across multiple \`\` blocks, the latest value will be used.\nhttps://svelte.dev/e/svelte_html_duplicate_attribute`, bold, normal); + } else { + console.warn(`https://svelte.dev/e/svelte_html_duplicate_attribute`); + } } \ No newline at end of file diff --git a/packages/svelte/src/legacy/legacy-server.js b/packages/svelte/src/legacy/legacy-server.js index 60bd8c9fc649..7eead7db7788 100644 --- a/packages/svelte/src/legacy/legacy-server.js +++ b/packages/svelte/src/legacy/legacy-server.js @@ -21,14 +21,15 @@ export { createClassComponent }; */ export function asClassComponent(component) { const component_constructor = as_class_component(component); - /** @type {(props?: {}, opts?: { $$slots?: {}; context?: Map; }) => { html: any; css: { code: string; map: any; }; head: string; } } */ + /** @type {(props?: {}, opts?: { $$slots?: {}; context?: Map; }) => { html: any; css: { code: string; map: any; }; head: string; htmlAttributes: string } } */ const _render = (props, { context } = {}) => { // @ts-expect-error the typings are off, but this will work if the component is compiled in SSR mode const result = render(component, { props, context }); return { css: { code: '', map: null }, head: result.head, - html: result.body + html: result.body, + htmlAttributes: result.htmlAttributes }; }; // @ts-expect-error this is present for SSR diff --git a/packages/svelte/svelte-html.d.ts b/packages/svelte/svelte-html.d.ts index 5042eaa4b849..d97ef5831593 100644 --- a/packages/svelte/svelte-html.d.ts +++ b/packages/svelte/svelte-html.d.ts @@ -242,6 +242,7 @@ declare global { 'svelte:window': HTMLProps<'svelte:window', HTMLAttributes>; 'svelte:body': HTMLProps<'svelte:body', HTMLAttributes>; 'svelte:document': HTMLProps<'svelte:document', HTMLAttributes>; + 'svelte:html': HTMLProps<'svelte:html', HTMLAttributes>; 'svelte:fragment': { slot?: string }; 'svelte:head': { [name: string]: any }; 'svelte:boundary': { diff --git a/packages/svelte/tests/compiler-errors/samples/svelte-selfdestructive/_config.js b/packages/svelte/tests/compiler-errors/samples/svelte-selfdestructive/_config.js index de5ee3bb6909..63c464c2562c 100644 --- a/packages/svelte/tests/compiler-errors/samples/svelte-selfdestructive/_config.js +++ b/packages/svelte/tests/compiler-errors/samples/svelte-selfdestructive/_config.js @@ -4,7 +4,7 @@ export default test({ error: { code: 'svelte_meta_invalid_tag', message: - 'Valid `` tag names are svelte:head, svelte:options, svelte:window, svelte:document, svelte:body, svelte:element, svelte:component, svelte:self, svelte:fragment or svelte:boundary', + 'Valid `` tag names are svelte:head, svelte:options, svelte:window, svelte:html, svelte:document, svelte:body, svelte:element, svelte:component, svelte:self, svelte:fragment or svelte:boundary', position: [10, 32] } }); diff --git a/packages/svelte/tests/hydration/samples/svelte-html-mismatch/_config.js b/packages/svelte/tests/hydration/samples/svelte-html-mismatch/_config.js new file mode 100644 index 000000000000..4443a3479187 --- /dev/null +++ b/packages/svelte/tests/hydration/samples/svelte-html-mismatch/_config.js @@ -0,0 +1,15 @@ +import { test } from '../../test'; + +export default test({ + server_props: { + lang: 'en' + }, + + props: { + lang: 'de' + }, + + test(assert, target) { + assert.htmlEqual(target.ownerDocument.documentElement.lang, 'de'); + } +}); diff --git a/packages/svelte/tests/hydration/samples/svelte-html-mismatch/main.svelte b/packages/svelte/tests/hydration/samples/svelte-html-mismatch/main.svelte new file mode 100644 index 000000000000..5868517a1cf4 --- /dev/null +++ b/packages/svelte/tests/hydration/samples/svelte-html-mismatch/main.svelte @@ -0,0 +1,5 @@ + + + diff --git a/packages/svelte/tests/runtime-legacy/shared.ts b/packages/svelte/tests/runtime-legacy/shared.ts index e6dc0f385bf9..fb6bc714c193 100644 --- a/packages/svelte/tests/runtime-legacy/shared.ts +++ b/packages/svelte/tests/runtime-legacy/shared.ts @@ -283,12 +283,21 @@ async function run_test_variant( config.before_test?.(); // ssr into target const SsrSvelteComponent = (await import(`${cwd}/_output/server/main.svelte.js`)).default; - const { html, head } = render(SsrSvelteComponent, { + const { head, body, htmlAttributes } = render(SsrSvelteComponent, { props: config.server_props ?? config.props ?? {} }); - fs.writeFileSync(`${cwd}/_output/rendered.html`, html); - target.innerHTML = html; + if (htmlAttributes) { + for (const [key, value] of htmlAttributes.split('" ').map((attr) => attr.split('='))) { + window.document.documentElement.setAttribute( + key, + value.slice(1, value.endsWith('"') ? -1 : undefined) + ); + } + } + + fs.writeFileSync(`${cwd}/_output/rendered.html`, body); + target.innerHTML = body; if (head) { fs.writeFileSync(`${cwd}/_output/rendered_head.html`, head); diff --git a/packages/svelte/tests/runtime-runes/samples/svelte-html-nested/_config.js b/packages/svelte/tests/runtime-runes/samples/svelte-html-nested/_config.js new file mode 100644 index 000000000000..0c60240475f4 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/svelte-html-nested/_config.js @@ -0,0 +1,45 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + compileOptions: { + dev: true + }, + async test({ assert, warnings }) { + assert.include(warnings[0], "Duplicate attribute 'foo' across multiple `` blocks"); + assert.deepEqual(document.documentElement.getAttribute('foo'), 'bar'); + assert.deepEqual(document.documentElement.getAttribute('class'), 'foo bar foo baz'); + + const [btn1, btn2] = document.querySelectorAll('button'); + + btn1.click(); + flushSync(); + assert.deepEqual(document.documentElement.getAttribute('foo'), 'foo'); + assert.deepEqual(document.documentElement.getAttribute('class'), 'foo bar'); + + btn1.click(); + flushSync(); + assert.deepEqual(document.documentElement.getAttribute('foo'), 'bar'); + assert.deepEqual(document.documentElement.getAttribute('class'), 'foo bar foo baz'); + + btn2.click(); + flushSync(); + assert.deepEqual(document.documentElement.getAttribute('foo'), 'top0'); + + btn1.click(); + flushSync(); + assert.deepEqual(document.documentElement.getAttribute('foo'), 'top0'); + + btn1.click(); + flushSync(); + assert.deepEqual(document.documentElement.getAttribute('foo'), 'bar'); + + document.querySelectorAll('button')[2].click(); + flushSync(); + assert.deepEqual(document.documentElement.getAttribute('foo'), 'nested0'); + + btn1.click(); + flushSync(); + assert.deepEqual(document.documentElement.getAttribute('foo'), 'top0'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/svelte-html-nested/child.svelte b/packages/svelte/tests/runtime-runes/samples/svelte-html-nested/child.svelte new file mode 100644 index 000000000000..f3942db7ed8b --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/svelte-html-nested/child.svelte @@ -0,0 +1,7 @@ + + + + diff --git a/packages/svelte/tests/runtime-runes/samples/svelte-html-nested/main.svelte b/packages/svelte/tests/runtime-runes/samples/svelte-html-nested/main.svelte new file mode 100644 index 000000000000..b5830a714001 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/svelte-html-nested/main.svelte @@ -0,0 +1,15 @@ + + + + + + + +{#if show} + +{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/svelte-html/_config.js b/packages/svelte/tests/runtime-runes/samples/svelte-html/_config.js new file mode 100644 index 000000000000..f843bf7df315 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/svelte-html/_config.js @@ -0,0 +1,10 @@ +import { test } from '../../test'; + +export default test({ + async test({ assert, target, logs }) { + assert.deepEqual(document.documentElement.lang, 'de'); + + target.querySelector('button')?.click(); + assert.deepEqual(logs, ['clicked']); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/svelte-html/main.svelte b/packages/svelte/tests/runtime-runes/samples/svelte-html/main.svelte new file mode 100644 index 000000000000..c91f05045da1 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/svelte-html/main.svelte @@ -0,0 +1,3 @@ + console.log('clicked')}> + + diff --git a/packages/svelte/tests/server-side-rendering/samples/svelte-html/Nested.svelte b/packages/svelte/tests/server-side-rendering/samples/svelte-html/Nested.svelte new file mode 100644 index 000000000000..8a2047d45915 --- /dev/null +++ b/packages/svelte/tests/server-side-rendering/samples/svelte-html/Nested.svelte @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/svelte/tests/server-side-rendering/samples/svelte-html/_config.js b/packages/svelte/tests/server-side-rendering/samples/svelte-html/_config.js new file mode 100644 index 000000000000..280ecb349307 --- /dev/null +++ b/packages/svelte/tests/server-side-rendering/samples/svelte-html/_config.js @@ -0,0 +1,5 @@ +import { test } from '../../test'; + +export default test({ + htmlAttributes: 'foo="bar"' +}); diff --git a/packages/svelte/tests/server-side-rendering/samples/svelte-html/_expected.html b/packages/svelte/tests/server-side-rendering/samples/svelte-html/_expected.html new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/svelte/tests/server-side-rendering/samples/svelte-html/main.svelte b/packages/svelte/tests/server-side-rendering/samples/svelte-html/main.svelte new file mode 100644 index 000000000000..e9211092ae54 --- /dev/null +++ b/packages/svelte/tests/server-side-rendering/samples/svelte-html/main.svelte @@ -0,0 +1,8 @@ + + + + + diff --git a/packages/svelte/tests/server-side-rendering/test.ts b/packages/svelte/tests/server-side-rendering/test.ts index f76c5b539f24..63708f1e9b5f 100644 --- a/packages/svelte/tests/server-side-rendering/test.ts +++ b/packages/svelte/tests/server-side-rendering/test.ts @@ -16,6 +16,7 @@ interface SSRTest extends BaseTest { compileOptions?: Partial; props?: Record; withoutNormalizeHtml?: boolean; + htmlAttributes?: string; errors?: string[]; } @@ -34,7 +35,7 @@ const { test, run } = suite(async (config, test_dir) => { const Component = (await import(`${test_dir}/_output/server/main.svelte.js`)).default; const expected_html = try_read_file(`${test_dir}/_expected.html`); const rendered = render(Component, { props: config.props || {} }); - const { body, head } = rendered; + const { body, head, htmlAttributes } = rendered; fs.writeFileSync(`${test_dir}/_output/rendered.html`, body); @@ -75,6 +76,10 @@ const { test, run } = suite(async (config, test_dir) => { } } + if (config.htmlAttributes) { + assert.deepEqual(htmlAttributes, config.htmlAttributes); + } + if (errors.length > 0) { assert.deepEqual(config.errors, errors); } diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index 671f68bff72a..fc86c9e729f7 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -1154,6 +1154,11 @@ declare module 'svelte/compiler' { type: 'RegularElement'; } + export interface SvelteHTML extends BaseElement { + type: 'SvelteHTML'; + name: 'svelte:html'; + } + export interface SvelteBody extends BaseElement { type: 'SvelteBody'; name: 'svelte:body'; @@ -1300,6 +1305,7 @@ declare module 'svelte/compiler' { | AST.TitleElement | AST.SlotElement | AST.RegularElement + | AST.SvelteHTML | AST.SvelteBody | AST.SvelteBoundary | AST.SvelteComponent @@ -2095,6 +2101,8 @@ declare module 'svelte/server' { html: string; /** HTML that goes somewhere into the `` */ body: string; + /** Attributes that go onto the `` */ + htmlAttributes: string; } export {}; diff --git a/playgrounds/sandbox/index.html b/playgrounds/sandbox/index.html index 512b5426a932..d80ee5ab753e 100644 --- a/playgrounds/sandbox/index.html +++ b/playgrounds/sandbox/index.html @@ -1,5 +1,5 @@ - + diff --git a/playgrounds/sandbox/ssr-dev.js b/playgrounds/sandbox/ssr-dev.js index 65390b70cac5..aac382d47d9c 100644 --- a/playgrounds/sandbox/ssr-dev.js +++ b/playgrounds/sandbox/ssr-dev.js @@ -23,9 +23,10 @@ polka() const template = fs.readFileSync(path.resolve(__dirname, 'index.html'), 'utf-8'); const transformed_template = await vite.transformIndexHtml(req.url, template); const { default: App } = await vite.ssrLoadModule('/src/main.svelte'); - const { head, body } = render(App); + const { head, body, htmlAttributes } = render(App); const html = transformed_template + .replace('%htmlAttributes%', htmlAttributes) .replace(``, head) .replace(``, body) // check that Safari doesn't break hydration diff --git a/playgrounds/sandbox/ssr-prod.js b/playgrounds/sandbox/ssr-prod.js index c268aac7902a..7aa38f1c2906 100644 --- a/playgrounds/sandbox/ssr-prod.js +++ b/playgrounds/sandbox/ssr-prod.js @@ -4,10 +4,11 @@ import polka from 'polka'; import { render } from 'svelte/server'; import App from './src/main.svelte'; -const { head, body } = render(App); +const { head, body, htmlAttributes } = render(App); const rendered = fs .readFileSync(path.resolve('./dist/client/index.html'), 'utf-8') + .replace('%htmlAttributes%', htmlAttributes) .replace(``, body) .replace(``, head);