From 81bd13173b05ee8c6b56f78ced4b6cc7dcbbf485 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Tue, 19 Nov 2024 18:31:12 +0100 Subject: [PATCH 01/16] feat: add `` element closes #8663 --- .changeset/lemon-paws-work.md | 5 ++ .../05-special-elements/04-svelte-html.md | 11 ++++ .../{04-svelte-head.md => 05-svelte-head.md} | 0 ...svelte-element.md => 06-svelte-element.md} | 0 ...svelte-options.md => 07-svelte-options.md} | 0 .../98-reference/.generated/compile-errors.md | 6 +++ packages/svelte/elements.d.ts | 1 + .../messages/compile-errors/template.md | 4 ++ packages/svelte/src/compiler/errors.js | 9 ++++ .../compiler/phases/1-parse/state/element.js | 1 + .../src/compiler/phases/2-analyze/index.js | 2 + .../phases/2-analyze/visitors/SvelteHTML.js | 21 ++++++++ .../3-transform/client/transform-client.js | 2 + .../3-transform/client/visitors/SvelteHTML.js | 53 +++++++++++++++++++ .../client/visitors/shared/events.js | 7 ++- .../client/visitors/shared/special_element.js | 2 +- .../3-transform/server/transform-server.js | 2 + .../3-transform/server/visitors/SvelteHTML.js | 28 ++++++++++ .../src/compiler/phases/3-transform/utils.js | 1 + .../svelte/src/compiler/types/template.d.ts | 6 +++ .../src/internal/server/blocks/svelte-html.js | 13 +++++ packages/svelte/src/internal/server/index.js | 17 ++++-- .../svelte/src/internal/server/types.d.ts | 3 ++ packages/svelte/svelte-html.d.ts | 1 + .../samples/svelte-selfdestructive/_config.js | 2 +- .../samples/svelte-html/_config.js | 7 +++ .../samples/svelte-html/main.svelte | 1 + .../samples/svelte-html/Nested.svelte | 1 + .../samples/svelte-html/_config.js | 5 ++ .../samples/svelte-html/_expected.html | 0 .../samples/svelte-html/main.svelte | 8 +++ .../tests/server-side-rendering/test.ts | 7 ++- packages/svelte/types/index.d.ts | 8 +++ playgrounds/sandbox/index.html | 2 +- playgrounds/sandbox/ssr-dev.js | 3 +- playgrounds/sandbox/ssr-prod.js | 3 +- 36 files changed, 232 insertions(+), 10 deletions(-) create mode 100644 .changeset/lemon-paws-work.md create mode 100644 documentation/docs/05-special-elements/04-svelte-html.md rename documentation/docs/05-special-elements/{04-svelte-head.md => 05-svelte-head.md} (100%) rename documentation/docs/05-special-elements/{05-svelte-element.md => 06-svelte-element.md} (100%) rename documentation/docs/05-special-elements/{06-svelte-options.md => 07-svelte-options.md} (100%) create mode 100644 packages/svelte/src/compiler/phases/2-analyze/visitors/SvelteHTML.js create mode 100644 packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteHTML.js create mode 100644 packages/svelte/src/compiler/phases/3-transform/server/visitors/SvelteHTML.js create mode 100644 packages/svelte/src/internal/server/blocks/svelte-html.js create mode 100644 packages/svelte/tests/runtime-runes/samples/svelte-html/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/svelte-html/main.svelte create mode 100644 packages/svelte/tests/server-side-rendering/samples/svelte-html/Nested.svelte create mode 100644 packages/svelte/tests/server-side-rendering/samples/svelte-html/_config.js create mode 100644 packages/svelte/tests/server-side-rendering/samples/svelte-html/_expected.html create mode 100644 packages/svelte/tests/server-side-rendering/samples/svelte-html/main.svelte 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..86e76f2a77b2 --- /dev/null +++ b/documentation/docs/05-special-elements/04-svelte-html.md @@ -0,0 +1,11 @@ +--- +title: +--- + +```svelte + +``` + +Similarly to ``, this element allows you to add properties and listeners to events on `document.documentElement`. This is useful for attributes such as `lang` which influence how the browser interprets the content. + +As with ``, `` and ``, 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/05-special-elements/04-svelte-head.md b/documentation/docs/05-special-elements/05-svelte-head.md similarity index 100% rename from documentation/docs/05-special-elements/04-svelte-head.md rename to documentation/docs/05-special-elements/05-svelte-head.md diff --git a/documentation/docs/05-special-elements/05-svelte-element.md b/documentation/docs/05-special-elements/06-svelte-element.md similarity index 100% rename from documentation/docs/05-special-elements/05-svelte-element.md rename to documentation/docs/05-special-elements/06-svelte-element.md diff --git a/documentation/docs/05-special-elements/06-svelte-options.md b/documentation/docs/05-special-elements/07-svelte-options.md similarity index 100% rename from documentation/docs/05-special-elements/06-svelte-options.md rename to documentation/docs/05-special-elements/07-svelte-options.md diff --git a/documentation/docs/98-reference/.generated/compile-errors.md b/documentation/docs/98-reference/.generated/compile-errors.md index 16cd361e52c5..198177cff59d 100644 --- a/documentation/docs/98-reference/.generated/compile-errors.md +++ b/documentation/docs/98-reference/.generated/compile-errors.md @@ -798,6 +798,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/packages/svelte/elements.d.ts b/packages/svelte/elements.d.ts index 8746b29e250a..bebc9b368f0f 100644 --- a/packages/svelte/elements.d.ts +++ b/packages/svelte/elements.d.ts @@ -1998,6 +1998,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 38ff87d505a5..2be95d6be52a 100644 --- a/packages/svelte/messages/compile-errors/template.md +++ b/packages/svelte/messages/compile-errors/template.md @@ -306,6 +306,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/src/compiler/errors.js b/packages/svelte/src/compiler/errors.js index ad2919b34bd8..2eb1fbf477e4 100644 --- a/packages/svelte/src/compiler/errors.js +++ b/packages/svelte/src/compiler/errors.js @@ -1282,6 +1282,15 @@ export function svelte_head_illegal_attribute(node) { e(node, "svelte_head_illegal_attribute", "`` cannot have attributes nor directives"); } +/** + * `` 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"); +} + /** * 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 9082b76c4972..b8e70d2d12c7 100644 --- a/packages/svelte/src/compiler/phases/1-parse/state/element.js +++ b/packages/svelte/src/compiler/phases/1-parse/state/element.js @@ -33,6 +33,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 a96652d60b0b..c59934b4f12c 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/index.js +++ b/packages/svelte/src/compiler/phases/2-analyze/index.js @@ -58,6 +58,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 { TaggedTemplateExpression } from './visitors/TaggedTemplateExpression.js'; @@ -169,6 +170,7 @@ const visitors = { SvelteElement, SvelteFragment, SvelteHead, + SvelteHTML, SvelteSelf, SvelteWindow, TaggedTemplateExpression, 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 5349f6025533..a35657fcc2ae 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 @@ -49,6 +49,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 { TitleElement } from './visitors/TitleElement.js'; @@ -123,6 +124,7 @@ const visitors = { SvelteElement, SvelteFragment, 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..929b3603497c --- /dev/null +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteHTML.js @@ -0,0 +1,53 @@ +/** @import { ExpressionStatement } from 'estree' */ +/** @import { AST } from '#compiler' */ +/** @import { ComponentContext } from '../types' */ +import { is_dom_property, normalize_attribute } from '../../../../../utils.js'; +import { is_ignored } from '../../../../state.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 node_id = b.id('$.document.documentElement'); + + for (const attribute of element.attributes) { + if (attribute.type === 'Attribute') { + if (is_event_attribute(attribute)) { + visit_event_attribute(attribute, context); + } else { + const name = normalize_attribute(attribute.name); + const { value, has_state } = build_attribute_value(attribute.value, context); + + /** @type {ExpressionStatement} */ + let update; + + if (name === 'class') { + update = b.stmt(b.call('$.set_class', node_id, value)); + } else if (is_dom_property(name)) { + update = b.stmt(b.assignment('=', b.member(node_id, name), value)); + } else { + update = b.stmt( + b.call( + '$.set_attribute', + node_id, + b.literal(name), + value, + is_ignored(element, 'hydration_attribute_changed') && b.true + ) + ); + } + + if (has_state) { + context.state.update.push(update); + } else { + context.state.init.push(update); + } + } + } + } +} 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 92f99c4a8b11..ae6318d623a6 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 {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 c164aa421916..8f1e49ab71e8 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'; @@ -74,6 +75,7 @@ const template_visitors = { SvelteElement, SvelteFragment, SvelteHead, + SvelteHTML, SvelteSelf, TitleElement }; 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 809c627098e8..eb780b112a8f 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 fd1824d3b3db..a0f21607f798 100644 --- a/packages/svelte/src/compiler/types/template.d.ts +++ b/packages/svelte/src/compiler/types/template.d.ts @@ -307,6 +307,11 @@ export namespace AST { }; } + export interface SvelteHTML extends BaseElement { + type: 'SvelteHTML'; + name: 'svelte:html'; + } + export interface SvelteBody extends BaseElement { type: 'SvelteBody'; name: 'svelte:body'; @@ -491,6 +496,7 @@ export type ElementLike = | AST.TitleElement | AST.SlotElement | AST.RegularElement + | AST.SvelteHTML | AST.SvelteBody | AST.SvelteComponent | AST.SvelteDocument 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..df2a5cddc250 --- /dev/null +++ b/packages/svelte/src/internal/server/blocks/svelte-html.js @@ -0,0 +1,13 @@ +/** @import { Payload } from '#server' */ + +import { escape } from '..'; + +/** + * @param {Payload} payload + * @param {Record} attributes + */ +export function svelte_html(payload, attributes) { + for (const name in attributes) { + payload.htmlAttributes.set(name, escape(attributes[name], true)); + } +} diff --git a/packages/svelte/src/internal/server/index.js b/packages/svelte/src/internal/server/index.js index 615a49fbd4c0..2b14d5c99580 100644 --- a/packages/svelte/src/internal/server/index.js +++ b/packages/svelte/src/internal/server/index.js @@ -31,9 +31,10 @@ const RAW_TEXT_ELEMENTS = ['textarea', 'script', 'style', 'title']; * @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, @@ -96,7 +97,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 = []; @@ -138,7 +144,10 @@ export function render(component, options = {}) { return { head, html: payload.out, - body: payload.out + body: payload.out, + htmlAttributes: [...payload.htmlAttributes] + .map(([name, value]) => `${name}="${value}"`) + .join(' ') }; } @@ -527,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/svelte-html.d.ts b/packages/svelte/svelte-html.d.ts index 50f7c25fc2c4..070a0610f318 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 }; // don't type svelte:options, it would override the types in svelte/elements and it isn't extendable anyway 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 371ddb81db50..b83a0bdf4990 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 or svelte:fragment', + 'Valid `` tag names are svelte:head, svelte:options, svelte:window, svelte:html, svelte:document, svelte:body, svelte:element, svelte:component, svelte:self or svelte:fragment', position: [10, 32] } }); 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..cea9bba2aa5a --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/svelte-html/_config.js @@ -0,0 +1,7 @@ +import { test } from '../../test'; + +export default test({ + async test({ assert }) { + assert.deepEqual(document.documentElement.lang, 'de'); + } +}); 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..cd4ed850b41a --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/svelte-html/main.svelte @@ -0,0 +1 @@ + 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 00ba2556d9a1..d07cf1c06724 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -1136,6 +1136,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'; @@ -1272,6 +1277,7 @@ declare module 'svelte/compiler' { | AST.TitleElement | AST.SlotElement | AST.RegularElement + | AST.SvelteHTML | AST.SvelteBody | AST.SvelteComponent | AST.SvelteDocument @@ -1744,6 +1750,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); From 5ba013efa3832d824af104e97d642ce34c62a015 Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Thu, 21 Nov 2024 21:16:30 +0100 Subject: [PATCH 02/16] Apply suggestions from code review --- packages/svelte/src/internal/server/blocks/svelte-html.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/svelte/src/internal/server/blocks/svelte-html.js b/packages/svelte/src/internal/server/blocks/svelte-html.js index df2a5cddc250..10e8be917fa5 100644 --- a/packages/svelte/src/internal/server/blocks/svelte-html.js +++ b/packages/svelte/src/internal/server/blocks/svelte-html.js @@ -1,6 +1,6 @@ /** @import { Payload } from '#server' */ -import { escape } from '..'; +import { escape_html } from '../../../escaping.js'; /** * @param {Payload} payload @@ -8,6 +8,6 @@ import { escape } from '..'; */ export function svelte_html(payload, attributes) { for (const name in attributes) { - payload.htmlAttributes.set(name, escape(attributes[name], true)); + payload.htmlAttributes.set(name, escape_html(attributes[name], true)); } } From a88e8148a703cee70b37945b5bb4c565f884de97 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Wed, 27 Nov 2024 12:08:49 +0100 Subject: [PATCH 03/16] warn on duplicates --- .../.generated/shared-warnings.md | 8 ++++++ .../messages/shared-warnings/warnings.md | 6 +++++ .../3-transform/client/visitors/SvelteHTML.js | 6 +++++ .../svelte/src/internal/client/validate.js | 27 ++++++++++++++++++- .../src/internal/server/blocks/svelte-html.js | 4 +++ .../svelte/src/internal/shared/warnings.js | 13 +++++++++ 6 files changed, 63 insertions(+), 1 deletion(-) diff --git a/documentation/docs/98-reference/.generated/shared-warnings.md b/documentation/docs/98-reference/.generated/shared-warnings.md index e9327e1e9818..ecc48468dca7 100644 --- a/documentation/docs/98-reference/.generated/shared-warnings.md +++ b/documentation/docs/98-reference/.generated/shared-warnings.md @@ -17,3 +17,11 @@ The following properties cannot be cloned with `$state.snapshot` — the return %properties% ``` + +### 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/messages/shared-warnings/warnings.md b/packages/svelte/messages/shared-warnings/warnings.md index c6cc81761ee3..df0473555ac8 100644 --- a/packages/svelte/messages/shared-warnings/warnings.md +++ b/packages/svelte/messages/shared-warnings/warnings.md @@ -9,3 +9,9 @@ > The following properties cannot be cloned with `$state.snapshot` — the return value contains the originals: > > %properties% + +## 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/phases/3-transform/client/visitors/SvelteHTML.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteHTML.js index 929b3603497c..082c82aa8104 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteHTML.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteHTML.js @@ -47,6 +47,12 @@ export function SvelteHTML(element, context) { } else { context.state.init.push(update); } + + if (context.state.options.dev) { + context.state.init.push( + b.stmt(b.call('$.validate_svelte_html_attribute', b.literal(name))) + ); + } } } } 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 index 10e8be917fa5..31ac205c8586 100644 --- a/packages/svelte/src/internal/server/blocks/svelte-html.js +++ b/packages/svelte/src/internal/server/blocks/svelte-html.js @@ -1,6 +1,7 @@ /** @import { Payload } from '#server' */ import { escape_html } from '../../../escaping.js'; +import { svelte_html_duplicate_attribute } from '../../shared/warnings.js'; /** * @param {Payload} payload @@ -8,6 +9,9 @@ import { escape_html } from '../../../escaping.js'; */ export function svelte_html(payload, attributes) { for (const name in attributes) { + if (payload.htmlAttributes.has(name)) { + svelte_html_duplicate_attribute(name); + } payload.htmlAttributes.set(name, escape_html(attributes[name], true)); } } diff --git a/packages/svelte/src/internal/shared/warnings.js b/packages/svelte/src/internal/shared/warnings.js index 37269a674eb5..ac31c88af573 100644 --- a/packages/svelte/src/internal/shared/warnings.js +++ b/packages/svelte/src/internal/shared/warnings.js @@ -35,4 +35,17 @@ ${properties}` // TODO print a link to the documentation console.warn("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.`, bold, normal); + } else { + // TODO print a link to the documentation + console.warn("svelte_html_duplicate_attribute"); + } } \ No newline at end of file From 86342319d86bba41c52fcdbe750d9de58ab7c086 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Fri, 6 Dec 2024 18:33:57 +0100 Subject: [PATCH 04/16] robustify for multiple blocks: - revert to previous value on unmount, if it was the last one changing the value - special handling for classes: merge them --- .../3-transform/client/visitors/SvelteHTML.js | 39 ++++---------- .../internal/client/dom/blocks/svelte-html.js | 54 +++++++++++++++++++ packages/svelte/src/internal/client/index.js | 8 ++- .../src/internal/server/blocks/svelte-html.js | 12 ++++- 4 files changed, 81 insertions(+), 32 deletions(-) create mode 100644 packages/svelte/src/internal/client/dom/blocks/svelte-html.js 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 index 082c82aa8104..f6e4f50c1af1 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteHTML.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteHTML.js @@ -1,8 +1,7 @@ -/** @import { ExpressionStatement } from 'estree' */ +/** @import { ExpressionStatement, Property } from 'estree' */ /** @import { AST } from '#compiler' */ /** @import { ComponentContext } from '../types' */ -import { is_dom_property, normalize_attribute } from '../../../../../utils.js'; -import { is_ignored } from '../../../../state.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/element.js'; @@ -13,7 +12,8 @@ import { visit_event_attribute } from './shared/events.js'; * @param {ComponentContext} context */ export function SvelteHTML(element, context) { - const node_id = b.id('$.document.documentElement'); + /** @type {Property[]} */ + const attributes = []; for (const attribute of element.attributes) { if (attribute.type === 'Attribute') { @@ -21,32 +21,9 @@ export function SvelteHTML(element, context) { visit_event_attribute(attribute, context); } else { const name = normalize_attribute(attribute.name); - const { value, has_state } = build_attribute_value(attribute.value, context); + const { value } = build_attribute_value(attribute.value, context); - /** @type {ExpressionStatement} */ - let update; - - if (name === 'class') { - update = b.stmt(b.call('$.set_class', node_id, value)); - } else if (is_dom_property(name)) { - update = b.stmt(b.assignment('=', b.member(node_id, name), value)); - } else { - update = b.stmt( - b.call( - '$.set_attribute', - node_id, - b.literal(name), - value, - is_ignored(element, 'hydration_attribute_changed') && b.true - ) - ); - } - - if (has_state) { - context.state.update.push(update); - } else { - context.state.init.push(update); - } + attributes.push(b.init(name, value)); if (context.state.options.dev) { context.state.init.push( @@ -56,4 +33,8 @@ export function SvelteHTML(element, context) { } } } + + 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/internal/client/dom/blocks/svelte-html.js b/packages/svelte/src/internal/client/dom/blocks/svelte-html.js new file mode 100644 index 000000000000..b69b6c76233c --- /dev/null +++ b/packages/svelte/src/internal/client/dom/blocks/svelte-html.js @@ -0,0 +1,54 @@ +import { render_effect, teardown } from '../../reactivity/effects.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 own = {}; + + /** @type {Record>} to check who set the last value of each attribute */ + // @ts-expect-error + const current_setters = (node.__attributes_setters ??= {}); + + /** @type {Record} */ + let attributes; + + render_effect(() => { + attributes = get_attributes(); + + for (const name in attributes) { + let value = attributes[name]; + current_setters[name] = (current_setters[name] ?? []).filter(([owner]) => owner !== own); + current_setters[name].unshift([own, value]); + + // Do nothing on initial render during hydration: If there are attribute duplicates, the last value + // wins, which could result in needless hydration repairs from earlier values. + if (hydrating) continue; + + if (name === 'class') { + set_class(node, current_setters[name].map(([_, value]) => value).join(' ')); + } else { + set_attribute(node, name, value); + } + } + }); + + teardown(() => { + for (const name in attributes) { + const old = current_setters[name]; + current_setters[name] = old.filter(([owner]) => owner !== own); + const current = current_setters[name]; + + if (name === 'class') { + set_class(node, current.map(([_, value]) => value).join(' ')); + } else if (old[0][0] === own) { + set_attribute(node, name, current[0]?.[1]); + } + } + }); +} diff --git a/packages/svelte/src/internal/client/index.js b/packages/svelte/src/internal/client/index.js index b706e52a5378..f38deeddb826 100644 --- a/packages/svelte/src/internal/client/index.js +++ b/packages/svelte/src/internal/client/index.js @@ -24,6 +24,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 { @@ -149,7 +150,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/server/blocks/svelte-html.js b/packages/svelte/src/internal/server/blocks/svelte-html.js index 31ac205c8586..adda28321fba 100644 --- a/packages/svelte/src/internal/server/blocks/svelte-html.js +++ b/packages/svelte/src/internal/server/blocks/svelte-html.js @@ -9,9 +9,17 @@ import { svelte_html_duplicate_attribute } from '../../shared/warnings.js'; */ export function svelte_html(payload, attributes) { for (const name in attributes) { + let value = attributes[name]; + if (payload.htmlAttributes.has(name)) { - svelte_html_duplicate_attribute(name); + if (name === 'class') { + // Don't bother deduplicating class names, the browser handles it just fine + value = `${payload.htmlAttributes.get(name)} ${value}`; + } else { + svelte_html_duplicate_attribute(name); + } } - payload.htmlAttributes.set(name, escape_html(attributes[name], true)); + + payload.htmlAttributes.set(name, escape_html(value, true)); } } From 0a9594913ce536df16b09c23d1a88a930f6e5ef6 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Fri, 6 Dec 2024 21:21:12 +0100 Subject: [PATCH 05/16] fix test setup --- packages/svelte/tests/runtime-legacy/shared.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/svelte/tests/runtime-legacy/shared.ts b/packages/svelte/tests/runtime-legacy/shared.ts index b14c0bdf4bd3..8f04385713bd 100644 --- a/packages/svelte/tests/runtime-legacy/shared.ts +++ b/packages/svelte/tests/runtime-legacy/shared.ts @@ -273,12 +273,18 @@ 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, -1)); + } + } + + fs.writeFileSync(`${cwd}/_output/rendered.html`, body); + target.innerHTML = body; if (head) { fs.writeFileSync(`${cwd}/_output/rendered_head.html`, head); From 8f8a5b10df6e232a2866341ef21bd6a5460a93b8 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Fri, 6 Dec 2024 21:52:47 +0100 Subject: [PATCH 06/16] tweak implementation --- .../internal/client/dom/blocks/svelte-html.js | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/svelte/src/internal/client/dom/blocks/svelte-html.js b/packages/svelte/src/internal/client/dom/blocks/svelte-html.js index b69b6c76233c..a1bd8306b51c 100644 --- a/packages/svelte/src/internal/client/dom/blocks/svelte-html.js +++ b/packages/svelte/src/internal/client/dom/blocks/svelte-html.js @@ -22,16 +22,22 @@ export function svelte_html(get_attributes) { attributes = get_attributes(); for (const name in attributes) { + const current = (current_setters[name] ??= []); + const idx = current.findIndex(([owner]) => owner === own); + const old = idx === -1 ? null : current.splice(idx, 1)[0][1]; + let value = attributes[name]; - current_setters[name] = (current_setters[name] ?? []).filter(([owner]) => owner !== own); - current_setters[name].unshift([own, value]); + current.push([own, value]); // Do nothing on initial render during hydration: If there are attribute duplicates, the last value // wins, which could result in needless hydration repairs from earlier values. if (hydrating) continue; if (name === 'class') { - set_class(node, current_setters[name].map(([_, value]) => value).join(' ')); + // Avoid unrelated attribute changes from triggering class changes + if (old !== value) { + set_class(node, current_setters[name].map(([_, text]) => text).join(' ')); + } } else { set_attribute(node, name, value); } @@ -45,9 +51,11 @@ export function svelte_html(get_attributes) { const current = current_setters[name]; if (name === 'class') { - set_class(node, current.map(([_, value]) => value).join(' ')); - } else if (old[0][0] === own) { - set_attribute(node, name, current[0]?.[1]); + set_class(node, current.map(([_, text]) => text).join(' ')); + + // If this was the last one setting this attribute, revert to the previous value + } else if (old[old.length - 1][0] === own) { + set_attribute(node, name, current[current.length - 1]?.[1]); } } }); From 6bf6c748ee5c14b20a1193e36643f20064638845 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Fri, 6 Dec 2024 21:52:59 +0100 Subject: [PATCH 07/16] test --- .../svelte/tests/runtime-legacy/shared.ts | 7 ++- .../samples/svelte-html-nested/_config.js | 45 +++++++++++++++++++ .../samples/svelte-html-nested/child.svelte | 7 +++ .../samples/svelte-html-nested/main.svelte | 15 +++++++ 4 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 packages/svelte/tests/runtime-runes/samples/svelte-html-nested/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/svelte-html-nested/child.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/svelte-html-nested/main.svelte diff --git a/packages/svelte/tests/runtime-legacy/shared.ts b/packages/svelte/tests/runtime-legacy/shared.ts index 8f04385713bd..2b105f0021c0 100644 --- a/packages/svelte/tests/runtime-legacy/shared.ts +++ b/packages/svelte/tests/runtime-legacy/shared.ts @@ -278,8 +278,11 @@ async function run_test_variant( }); if (htmlAttributes) { - for (const [key, value] of htmlAttributes.split(' ').map((attr) => attr.split('='))) { - window.document.documentElement.setAttribute(key, value.slice(1, -1)); + for (const [key, value] of htmlAttributes.split('" ').map((attr) => attr.split('='))) { + window.document.documentElement.setAttribute( + key, + value.slice(1, value.endsWith('"') ? -1 : undefined) + ); } } 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} From e33e2cb685401419ecd054f8b26a78bd9b2fcf04 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Mon, 16 Dec 2024 14:23:57 +0100 Subject: [PATCH 08/16] regenerate --- packages/svelte/src/compiler/errors.js | 2 +- packages/svelte/src/internal/shared/warnings.js | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/svelte/src/compiler/errors.js b/packages/svelte/src/compiler/errors.js index d991b266ca43..e7e0c549e46c 100644 --- a/packages/svelte/src/compiler/errors.js +++ b/packages/svelte/src/compiler/errors.js @@ -1361,7 +1361,7 @@ export function svelte_head_illegal_attribute(node) { * @returns {never} */ export function svelte_html_illegal_attribute(node) { - e(node, "svelte_html_illegal_attribute", "`` can only have regular attributes"); + e(node, "svelte_html_illegal_attribute", `\`\` can only have regular attributes\nhttps://svelte.dev/e/svelte_html_illegal_attribute`); } /** diff --git a/packages/svelte/src/internal/shared/warnings.js b/packages/svelte/src/internal/shared/warnings.js index c948b6d525b7..38c2bc634c31 100644 --- a/packages/svelte/src/internal/shared/warnings.js +++ b/packages/svelte/src/internal/shared/warnings.js @@ -41,9 +41,8 @@ ${properties}` */ 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.`, bold, normal); + 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 { - // TODO print a link to the documentation - console.warn("svelte_html_duplicate_attribute"); + console.warn(`https://svelte.dev/e/svelte_html_duplicate_attribute`); } } \ No newline at end of file From 7c629e898814342135880e00a6552fda600e5c7d Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 17 Dec 2024 17:39:08 -0500 Subject: [PATCH 09/16] Update packages/svelte/src/internal/client/dom/blocks/svelte-html.js --- packages/svelte/src/internal/client/dom/blocks/svelte-html.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/svelte/src/internal/client/dom/blocks/svelte-html.js b/packages/svelte/src/internal/client/dom/blocks/svelte-html.js index a1bd8306b51c..dd1b20f36c2b 100644 --- a/packages/svelte/src/internal/client/dom/blocks/svelte-html.js +++ b/packages/svelte/src/internal/client/dom/blocks/svelte-html.js @@ -23,8 +23,8 @@ export function svelte_html(get_attributes) { for (const name in attributes) { const current = (current_setters[name] ??= []); - const idx = current.findIndex(([owner]) => owner === own); - const old = idx === -1 ? null : current.splice(idx, 1)[0][1]; + const index = current.findIndex(([owner]) => owner === own); + const old = index === -1 ? null : current.splice(index, 1)[0][1]; let value = attributes[name]; current.push([own, value]); From eb7a516770250490162b2fdf3e53a8751d23445c Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Wed, 18 Dec 2024 10:16:01 +0100 Subject: [PATCH 10/16] better server attribute stringification --- packages/svelte/src/internal/server/blocks/svelte-html.js | 5 ++--- packages/svelte/src/internal/server/index.js | 5 +++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/svelte/src/internal/server/blocks/svelte-html.js b/packages/svelte/src/internal/server/blocks/svelte-html.js index adda28321fba..d4b2506dfe5a 100644 --- a/packages/svelte/src/internal/server/blocks/svelte-html.js +++ b/packages/svelte/src/internal/server/blocks/svelte-html.js @@ -1,6 +1,5 @@ /** @import { Payload } from '#server' */ -import { escape_html } from '../../../escaping.js'; import { svelte_html_duplicate_attribute } from '../../shared/warnings.js'; /** @@ -14,12 +13,12 @@ export function svelte_html(payload, attributes) { 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}`; + value = `${payload.htmlAttributes.get(name)} ${value}`.trim(); } else { svelte_html_duplicate_attribute(name); } } - payload.htmlAttributes.set(name, escape_html(value, true)); + payload.htmlAttributes.set(name, value); } } diff --git a/packages/svelte/src/internal/server/index.js b/packages/svelte/src/internal/server/index.js index ff1fb7724e37..d9fc7d757345 100644 --- a/packages/svelte/src/internal/server/index.js +++ b/packages/svelte/src/internal/server/index.js @@ -143,8 +143,9 @@ export function render(component, options = {}) { html: payload.out, body: payload.out, htmlAttributes: [...payload.htmlAttributes] - .map(([name, value]) => `${name}="${value}"`) - .join(' ') + .map(([name, value]) => attr(name, value, is_boolean_attribute(name))) + .join('') + .trim() }; } From cea622af8bf5362a21c39bd7ea3e0728bd5c1d6b Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Wed, 18 Dec 2024 18:45:31 +0100 Subject: [PATCH 11/16] better docs --- .../docs/05-special-elements/04-svelte-html.md | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/documentation/docs/05-special-elements/04-svelte-html.md b/documentation/docs/05-special-elements/04-svelte-html.md index 86e76f2a77b2..9708a9f0ec77 100644 --- a/documentation/docs/05-special-elements/04-svelte-html.md +++ b/documentation/docs/05-special-elements/04-svelte-html.md @@ -6,6 +6,17 @@ title: ``` -Similarly to ``, this element allows you to add properties and listeners to events on `document.documentElement`. This is useful for attributes such as `lang` which influence how the browser interprets the content. +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. -As with ``, `` and ``, this element may only appear the top level of your component and must never be inside a block or element. +```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. From edf31f1ef03f9024b27e12e2da486d75090434ea Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Wed, 18 Dec 2024 21:10:03 +0100 Subject: [PATCH 12/16] naming things --- .../internal/client/dom/blocks/svelte-html.js | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/svelte/src/internal/client/dom/blocks/svelte-html.js b/packages/svelte/src/internal/client/dom/blocks/svelte-html.js index dd1b20f36c2b..10e2e2b8f44c 100644 --- a/packages/svelte/src/internal/client/dom/blocks/svelte-html.js +++ b/packages/svelte/src/internal/client/dom/blocks/svelte-html.js @@ -9,9 +9,9 @@ import { hydrating } from '../hydration.js'; */ export function svelte_html(get_attributes) { const node = document.documentElement; - const own = {}; + const self = {}; - /** @type {Record>} to check who set the last value of each attribute */ + /** @type {Record>} to check who set the last value of each attribute */ // @ts-expect-error const current_setters = (node.__attributes_setters ??= {}); @@ -23,11 +23,11 @@ export function svelte_html(get_attributes) { for (const name in attributes) { const current = (current_setters[name] ??= []); - const index = current.findIndex(([owner]) => owner === own); - const old = index === -1 ? null : current.splice(index, 1)[0][1]; + 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([own, value]); + current.push({ owner: self, value }); // Do nothing on initial render during hydration: If there are attribute duplicates, the last value // wins, which could result in needless hydration repairs from earlier values. @@ -36,7 +36,7 @@ export function svelte_html(get_attributes) { if (name === 'class') { // Avoid unrelated attribute changes from triggering class changes if (old !== value) { - set_class(node, current_setters[name].map(([_, text]) => text).join(' ')); + set_class(node, current_setters[name].map((e) => e.value).join(' ')); } } else { set_attribute(node, name, value); @@ -47,15 +47,15 @@ export function svelte_html(get_attributes) { teardown(() => { for (const name in attributes) { const old = current_setters[name]; - current_setters[name] = old.filter(([owner]) => owner !== own); + current_setters[name] = old.filter((o) => o.owner !== self); const current = current_setters[name]; if (name === 'class') { - set_class(node, current.map(([_, text]) => text).join(' ')); + 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][0] === own) { - set_attribute(node, name, current[current.length - 1]?.[1]); + } else if (old[old.length - 1].owner === self) { + set_attribute(node, name, current[current.length - 1]?.value); } } }); From c2f97c7894b36bf2dc0f8cc47b69d549e06d1d7f Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Wed, 18 Dec 2024 21:46:23 +0100 Subject: [PATCH 13/16] repair hydration mismatches --- .../internal/client/dom/blocks/svelte-html.js | 24 +++++++++++++------ .../samples/svelte-html-mismatch/_config.js | 15 ++++++++++++ .../samples/svelte-html-mismatch/main.svelte | 5 ++++ 3 files changed, 37 insertions(+), 7 deletions(-) create mode 100644 packages/svelte/tests/hydration/samples/svelte-html-mismatch/_config.js create mode 100644 packages/svelte/tests/hydration/samples/svelte-html-mismatch/main.svelte diff --git a/packages/svelte/src/internal/client/dom/blocks/svelte-html.js b/packages/svelte/src/internal/client/dom/blocks/svelte-html.js index 10e2e2b8f44c..afa6605fa9c3 100644 --- a/packages/svelte/src/internal/client/dom/blocks/svelte-html.js +++ b/packages/svelte/src/internal/client/dom/blocks/svelte-html.js @@ -1,4 +1,5 @@ -import { render_effect, teardown } from '../../reactivity/effects.js'; +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'; @@ -15,10 +16,10 @@ export function svelte_html(get_attributes) { // @ts-expect-error const current_setters = (node.__attributes_setters ??= {}); - /** @type {Record} */ + /** @type {Record} Does _not_ contain event listeners, those are handled separately */ let attributes; - render_effect(() => { + const set_html_attributes = () => { attributes = get_attributes(); for (const name in attributes) { @@ -29,9 +30,16 @@ export function svelte_html(get_attributes) { let value = attributes[name]; current.push({ owner: self, value }); - // Do nothing on initial render during hydration: If there are attribute duplicates, the last value - // wins, which could result in needless hydration repairs from earlier values. - if (hydrating) continue; + // 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) { + untrack(set_html_attributes); + } + }); + return; + } if (name === 'class') { // Avoid unrelated attribute changes from triggering class changes @@ -42,7 +50,9 @@ export function svelte_html(get_attributes) { set_attribute(node, name, value); } } - }); + }; + + render_effect(set_html_attributes); teardown(() => { for (const name in attributes) { 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 @@ + + + From c30cf7121bfda90edd33cc1ec90f4162fa070d8e Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Wed, 18 Dec 2024 21:56:09 +0100 Subject: [PATCH 14/16] fix event handling --- .../phases/3-transform/client/visitors/SvelteHTML.js | 6 +++++- .../svelte/src/internal/client/dom/elements/events.js | 10 ++++++++-- .../tests/runtime-runes/samples/svelte-html/_config.js | 5 ++++- .../runtime-runes/samples/svelte-html/main.svelte | 4 +++- 4 files changed, 20 insertions(+), 5 deletions(-) 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 index f6e4f50c1af1..3f239b930e90 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteHTML.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteHTML.js @@ -12,13 +12,17 @@ import { visit_event_attribute } from './shared/events.js'; * @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, context); + visit_event_attribute(attribute, event_context); } else { const name = normalize_attribute(attribute.name); const { value } = build_attribute_value(attribute.value, context); 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/tests/runtime-runes/samples/svelte-html/_config.js b/packages/svelte/tests/runtime-runes/samples/svelte-html/_config.js index cea9bba2aa5a..f843bf7df315 100644 --- a/packages/svelte/tests/runtime-runes/samples/svelte-html/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/svelte-html/_config.js @@ -1,7 +1,10 @@ import { test } from '../../test'; export default test({ - async test({ assert }) { + 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 index cd4ed850b41a..c91f05045da1 100644 --- a/packages/svelte/tests/runtime-runes/samples/svelte-html/main.svelte +++ b/packages/svelte/tests/runtime-runes/samples/svelte-html/main.svelte @@ -1 +1,3 @@ - + console.log('clicked')}> + + From c7232220ad7023dfebb810a89c468cd5c374f841 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Wed, 18 Dec 2024 22:24:26 +0100 Subject: [PATCH 15/16] fix --- .../internal/client/dom/blocks/svelte-html.js | 29 ++++++++++--------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/packages/svelte/src/internal/client/dom/blocks/svelte-html.js b/packages/svelte/src/internal/client/dom/blocks/svelte-html.js index afa6605fa9c3..a96bd41b5329 100644 --- a/packages/svelte/src/internal/client/dom/blocks/svelte-html.js +++ b/packages/svelte/src/internal/client/dom/blocks/svelte-html.js @@ -19,7 +19,7 @@ export function svelte_html(get_attributes) { /** @type {Record} Does _not_ contain event listeners, those are handled separately */ let attributes; - const set_html_attributes = () => { + render_effect(() => { attributes = get_attributes(); for (const name in attributes) { @@ -30,29 +30,30 @@ export function svelte_html(get_attributes) { 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) { - untrack(set_html_attributes); + set(); } }); - return; - } - - 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); + set(); } } - }; - - render_effect(set_html_attributes); + }); teardown(() => { for (const name in attributes) { From 75e1deafe00d4317a1d48ca8d50f7e054850a384 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Wed, 18 Dec 2024 23:18:49 +0100 Subject: [PATCH 16/16] add to legacy wrapper --- packages/svelte/src/legacy/legacy-server.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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