From 81bd13173b05ee8c6b56f78ced4b6cc7dcbbf485 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Tue, 19 Nov 2024 18:31:12 +0100 Subject: [PATCH 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 6/7] 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 7/7] 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}