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 22 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
22 changes: 22 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,22 @@
---
title: <svelte:html>
---

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

This element allows you to add attributes and event listeners to the `<html>` root (i.e. `document.documentElement`). This is useful for attributes such as `lang` which influence how the browser interprets the content.

```svelte
<!--- file: +layout.svelte --->
<script>
let { data } = $props();
</script>

<svelte:html lang={data.lang}></svelte:html>
```

> [!NOTE] If you use SvelteKit version 2.13 or higher (and have `%svelte.htmlAttributes%` on the `<html>` 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.
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,44 @@
/** @import { ExpressionStatement, Property } from 'estree' */
/** @import { AST } from '#compiler' */
/** @import { ComponentContext } from '../types' */
import { normalize_attribute } from '../../../../../utils.js';
import { is_event_attribute } from '../../../../utils/ast.js';
import * as b from '../../../../utils/builders.js';
import { build_attribute_value } from './shared/element.js';
import { visit_event_attribute } from './shared/events.js';

/**
* @param {AST.SvelteHTML} element
* @param {ComponentContext} context
*/
export function SvelteHTML(element, context) {
const event_context = {
...context,
state: { ...context.state, node: b.id('$.document.documentElement') }
};
/** @type {Property[]} */
const attributes = [];

for (const attribute of element.attributes) {
if (attribute.type === 'Attribute') {
if (is_event_attribute(attribute)) {
visit_event_attribute(attribute, event_context);
} else {
const name = normalize_attribute(attribute.name);
const { value } = build_attribute_value(attribute.value, context);

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

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

if (attributes.length > 0) {
context.state.init.push(b.stmt(b.call('$.svelte_html', b.arrow([], b.object(attributes)))));
}
}
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
73 changes: 73 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,73 @@
import { effect, render_effect, teardown } from '../../reactivity/effects.js';
import { untrack } from '../../runtime.js';
import { set_attribute } from '../elements/attributes.js';
import { set_class } from '../elements/class.js';
import { hydrating } from '../hydration.js';

/**
* @param {() => Record<string, any>} get_attributes
* @returns {void}
*/
export function svelte_html(get_attributes) {
const node = document.documentElement;
const self = {};

/** @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>} Does _not_ contain event listeners, those are handled separately */
let attributes;

render_effect(() => {
attributes = get_attributes();

for (const name in attributes) {
const current = (current_setters[name] ??= []);
const index = current.findIndex((c) => c.owner === self);
const old = index === -1 ? null : current.splice(index, 1)[0].value;

let value = attributes[name];
current.push({ owner: self, value });

const set = () => {
if (name === 'class') {
// Avoid unrelated attribute changes from triggering class changes
if (old !== value) {
set_class(node, current_setters[name].map((e) => e.value).join(' '));
}
} else {
set_attribute(node, name, value);
}
};

// Defer hydration on initial render during hydration: If there are attribute duplicates, the last value
// wins, so we wait until all values have been set to see if we're actually the last one that sets the value.
if (hydrating) {
effect(() => {
if (current[current.length - 1].owner === self) {
set();
}
});
} else {
set();
}
}
});

teardown(() => {
for (const name in attributes) {
const old = current_setters[name];
current_setters[name] = old.filter((o) => o.owner !== self);
const current = current_setters[name];

if (name === 'class') {
set_class(node, current.map((c) => c.value).join(' '));

// If this was the last one setting this attribute, revert to the previous value
} else if (old[old.length - 1].owner === self) {
set_attribute(node, name, current[current.length - 1]?.value);
}
}
});
}
10 changes: 8 additions & 2 deletions packages/svelte/src/internal/client/dom/elements/events.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
Loading
Loading