Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add <svelte:html> element #14397

Draft
wants to merge 14 commits into
base: main
Choose a base branch
from
5 changes: 5 additions & 0 deletions .changeset/lemon-paws-work.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'svelte': minor
---

feat: add `<svelte:html>` element
11 changes: 11 additions & 0 deletions documentation/docs/05-special-elements/04-svelte-html.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
title: <svelte:html>
---

```svelte
<svelte:html attribute={value} onevent={handler} />
```

Similarly to `<svelte:body>`, 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 `<svelte:window>`, `<svelte:document>` and `<svelte:body>`, this element may only appear the top level of your component and must never be inside a block or element.
6 changes: 6 additions & 0 deletions documentation/docs/98-reference/.generated/compile-errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -864,6 +864,12 @@ Invalid component definition — must be an `{expression}`
`<svelte:head>` cannot have attributes nor directives
```

### svelte_html_illegal_attribute

```
`<svelte:html>` can only have regular attributes
```

### svelte_meta_duplicate

```
Expand Down
8 changes: 8 additions & 0 deletions documentation/docs/98-reference/.generated/shared-warnings.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,11 @@ The following properties cannot be cloned with `$state.snapshot` — the return
const object = $state({ property: 'this is cloneable', window })
const snapshot = $state.snapshot(object);
```

### svelte_html_duplicate_attribute

```
Duplicate attribute '%name%' across multiple `<svelte:html>` blocks, the latest value will be used.
```

This warning appears when you have multiple `<svelte:html>` 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 `<svelte:html>` blocks.
1 change: 1 addition & 0 deletions packages/svelte/elements.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2024,6 +2024,7 @@ export interface SvelteHTMLElements {
'svelte:window': SvelteWindowAttributes;
'svelte:document': SvelteDocumentAttributes;
'svelte:body': HTMLAttributes<HTMLElement>;
'svelte:html': HTMLAttributes<HTMLElement>;
'svelte:fragment': { slot?: string };
'svelte:options': {
customElement?:
Expand Down
4 changes: 4 additions & 0 deletions packages/svelte/messages/compile-errors/template.md
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,10 @@ HTML restricts where certain elements can appear. In case of a violation the bro

> `<svelte:head>` cannot have attributes nor directives

## svelte_html_illegal_attribute

> `<svelte:html>` can only have regular attributes

## svelte_meta_duplicate

> A component can only have one `<%name%>` element
Expand Down
6 changes: 6 additions & 0 deletions packages/svelte/messages/shared-warnings/warnings.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,9 @@ Elements such as `<input>` cannot have content, any children passed to these ele
const object = $state({ property: 'this is cloneable', window })
const snapshot = $state.snapshot(object);
```

## svelte_html_duplicate_attribute

> Duplicate attribute '%name%' across multiple `<svelte:html>` blocks, the latest value will be used.

This warning appears when you have multiple `<svelte:html>` 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 `<svelte:html>` blocks.
9 changes: 9 additions & 0 deletions packages/svelte/src/compiler/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -1355,6 +1355,15 @@ export function svelte_head_illegal_attribute(node) {
e(node, "svelte_head_illegal_attribute", `\`<svelte:head>\` cannot have attributes nor directives\nhttps://svelte.dev/e/svelte_head_illegal_attribute`);
}

/**
* `<svelte:html>` 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", `\`<svelte:html>\` can only have regular attributes\nhttps://svelte.dev/e/svelte_html_illegal_attribute`);
}

/**
* A component can only have one `<%name%>` element
* @param {null | number | NodeLike} node
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const root_only_meta_tags = new Map([
['svelte:head', 'SvelteHead'],
['svelte:options', 'SvelteOptions'],
['svelte:window', 'SvelteWindow'],
['svelte:html', 'SvelteHTML'],
['svelte:document', 'SvelteDocument'],
['svelte:body', 'SvelteBody']
]);
Expand Down
2 changes: 2 additions & 0 deletions packages/svelte/src/compiler/phases/2-analyze/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import { SvelteDocument } from './visitors/SvelteDocument.js';
import { SvelteElement } from './visitors/SvelteElement.js';
import { SvelteFragment } from './visitors/SvelteFragment.js';
import { SvelteHead } from './visitors/SvelteHead.js';
import { SvelteHTML } from './visitors/SvelteHTML.js';
import { SvelteSelf } from './visitors/SvelteSelf.js';
import { SvelteWindow } from './visitors/SvelteWindow.js';
import { SvelteBoundary } from './visitors/SvelteBoundary.js';
Expand Down Expand Up @@ -172,6 +173,7 @@ const visitors = {
SvelteElement,
SvelteFragment,
SvelteHead,
SvelteHTML,
SvelteSelf,
SvelteWindow,
SvelteBoundary,
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import { SvelteElement } from './visitors/SvelteElement.js';
import { SvelteFragment } from './visitors/SvelteFragment.js';
import { SvelteBoundary } from './visitors/SvelteBoundary.js';
import { SvelteHead } from './visitors/SvelteHead.js';
import { SvelteHTML } from './visitors/SvelteHTML.js';
import { SvelteSelf } from './visitors/SvelteSelf.js';
import { SvelteWindow } from './visitors/SvelteWindow.js';
import { TitleElement } from './visitors/TitleElement.js';
Expand Down Expand Up @@ -125,6 +126,7 @@ const visitors = {
SvelteFragment,
SvelteBoundary,
SvelteHead,
SvelteHTML,
SvelteSelf,
SvelteWindow,
TitleElement,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/** @import { ExpressionStatement, Property } from 'estree' */
/** @import { AST } from '#compiler' */
/** @import { ComponentContext } from '../types' */
import { normalize_attribute } from '../../../../../utils.js';
import { is_event_attribute } from '../../../../utils/ast.js';
import * as b from '../../../../utils/builders.js';
import { build_attribute_value } from './shared/element.js';
import { visit_event_attribute } from './shared/events.js';

/**
* @param {AST.SvelteHTML} element
* @param {ComponentContext} context
*/
export function SvelteHTML(element, context) {
/** @type {Property[]} */
const attributes = [];

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 } = build_attribute_value(attribute.value, context);

attributes.push(b.init(name, value));

if (context.state.options.dev) {
context.state.init.push(
b.stmt(b.call('$.validate_svelte_html_attribute', b.literal(name)))
);
}
}
}
}

if (attributes.length > 0) {
context.state.init.push(b.stmt(b.call('$.svelte_html', b.arrow([], b.object(attributes)))));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,12 @@ export function visit_event_attribute(node, context) {

const type = /** @type {AST.SvelteNode} */ (context.path.at(-1)).type;

if (type === 'SvelteDocument' || type === 'SvelteWindow' || type === 'SvelteBody') {
if (
type === 'SvelteDocument' ||
type === 'SvelteWindow' ||
type === 'SvelteBody' ||
type === 'SvelteHTML'
) {
// These nodes are above the component tree, and its events should run parent first
context.state.init.push(statement);
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -75,6 +76,7 @@ const template_visitors = {
SvelteElement,
SvelteFragment,
SvelteHead,
SvelteHTML,
SvelteSelf,
TitleElement,
SvelteBoundary
Expand Down
Original file line number Diff line number Diff line change
@@ -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)))
);
}
1 change: 1 addition & 0 deletions packages/svelte/src/compiler/phases/3-transform/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' ||
Expand Down
6 changes: 6 additions & 0 deletions packages/svelte/src/compiler/types/template.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,11 @@ export namespace AST {
};
}

export interface SvelteHTML extends BaseElement {
type: 'SvelteHTML';
name: 'svelte:html';
}

export interface SvelteBody extends BaseElement {
type: 'SvelteBody';
name: 'svelte:body';
Expand Down Expand Up @@ -525,6 +530,7 @@ export namespace AST {
| AST.TitleElement
| AST.SlotElement
| AST.RegularElement
| AST.SvelteHTML
| AST.SvelteBody
| AST.SvelteBoundary
| AST.SvelteComponent
Expand Down
62 changes: 62 additions & 0 deletions packages/svelte/src/internal/client/dom/blocks/svelte-html.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
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<string, any>} get_attributes
* @returns {void}
*/
export function svelte_html(get_attributes) {
const node = document.documentElement;
const own = {};
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like 'owner' makes more sense here

Suggested change
const own = {};
const owner = {};


/** @type {Record<string, Array<[any, any]>>} to check who set the last value of each attribute */
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think tuples are unnecessarily confusing

Suggested change
/** @type {Record<string, Array<[any, any]>>} to check who set the last value of each attribute */
/** @type {Record<string, Array<{ owner: any, value: any }>>} to check who set the last value of each attribute */

// @ts-expect-error
const current_setters = (node.__attributes_setters ??= {});

/** @type {Record<string, any>} */
let attributes;

render_effect(() => {
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];
Rich-Harris marked this conversation as resolved.
Show resolved Hide resolved

let value = attributes[name];
current.push([own, value]);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
current.push([own, value]);
current.push({ owner, 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;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This results in mismatches — say for example you have something like this (however inadvisable it may be):

<script>
	let theme = typeof window !== 'undefined'
		? matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
		: undefined;
</script>

<svelte:html data-theme={theme} />


if (name === 'class') {
// Avoid unrelated attribute changes from triggering class changes
if (old !== value) {
set_class(node, current_setters[name].map(([_, text]) => text).join(' '));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
set_class(node, current_setters[name].map(([_, text]) => text).join(' '));
set_class(node, current_setters[name].map((a) => a.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);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
current_setters[name] = old.filter(([owner]) => owner !== own);
current_setters[name] = old.filter((a) => a.owner !== owner);

const current = current_setters[name];

if (name === 'class') {
set_class(node, current.map(([_, text]) => text).join(' '));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
set_class(node, current.map(([_, text]) => text).join(' '));
set_class(node, current.map((a) => a.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]);
Comment on lines +57 to +58
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
} else if (old[old.length - 1][0] === own) {
set_attribute(node, name, current[current.length - 1]?.[1]);
} else if (old[old.length - 1].owner === owner) {
set_attribute(node, name, current[current.length - 1]?.value);

}
}
});
}
8 changes: 7 additions & 1 deletion packages/svelte/src/internal/client/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export { snippet, wrap_snippet } from './dom/blocks/snippet.js';
export { component } from './dom/blocks/svelte-component.js';
export { element } from './dom/blocks/svelte-element.js';
export { head } from './dom/blocks/svelte-head.js';
export { svelte_html } from './dom/blocks/svelte-html.js';
export { append_styles } from './dom/css.js';
export { action } from './dom/elements/actions.js';
export {
Expand Down Expand Up @@ -150,7 +151,12 @@ export {
setContext,
hasContext
} from './runtime.js';
export { validate_binding, validate_each_keys, validate_prop_bindings } from './validate.js';
export {
validate_binding,
validate_each_keys,
validate_prop_bindings,
validate_svelte_html_attribute
} from './validate.js';
export { raf } from './timing.js';
export { proxy } from './proxy.js';
export { create_custom_element } from './dom/elements/custom-element.js';
Expand Down
Loading
Loading