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 14 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
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 = {};
dummdidumm marked this conversation as resolved.
Show resolved Hide resolved

/** @type {Record<string, Array<[any, any]>>} to check who set the last value of each attribute */
dummdidumm marked this conversation as resolved.
Show resolved Hide resolved
// @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 index = current.findIndex(([owner]) => owner === own);
const old = index === -1 ? null : current.splice(index, 1)[0][1];
dummdidumm marked this conversation as resolved.
Show resolved Hide resolved

let value = attributes[name];
current.push([own, value]);
dummdidumm marked this conversation as resolved.
Show resolved Hide resolved

// 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;
dummdidumm marked this conversation as resolved.
Show resolved Hide resolved

if (name === 'class') {
// Avoid unrelated attribute changes from triggering class changes
if (old !== value) {
set_class(node, current_setters[name].map(([_, text]) => text).join(' '));
dummdidumm marked this conversation as resolved.
Show resolved Hide resolved
}
} 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);
dummdidumm marked this conversation as resolved.
Show resolved Hide resolved
const current = current_setters[name];

if (name === 'class') {
set_class(node, current.map(([_, text]) => text).join(' '));
dummdidumm marked this conversation as resolved.
Show resolved Hide resolved

// 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]);
dummdidumm marked this conversation as resolved.
Show resolved Hide resolved
}
}
});
}
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