feat: attachments (#15000)

* parse attachments

* basic attachments working

* working

* rename to attach

* fix

* restrict which symbols are recognised as attachment keys

* allow cleanup to be returned directly

* changeset

* fix

* lint

* remove createAttachmentKey/isAttachmentKey

* fix spreading of symbol properties onto component

* types

* fix

* update name

* reserve ability to use sequence expressions in future

* Update packages/svelte/src/internal/client/dom/elements/attachments.js

Co-authored-by: Leonidaz <lbarenblit@gmail.com>

* actually let's do this instead

* expose createAttachmentKey

* make room for `@attach` docs

* add docs

* failing test

* fix

* lock down

* add missing reference docs

* prevent conflicts

* update docs

* regenerate

* fix link

* add Attachment interface

* beef up test

* regenerate

* tweak types

* fix

---------

Co-authored-by: Leonidaz <lbarenblit@gmail.com>
pull/15914/head
Rich Harris 4 months ago committed by GitHub
parent 7636031b0d
commit b93a617aa8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': minor
---
feat: attachments

@ -0,0 +1,138 @@
---
title: {@attach ...}
---
Attachments are functions that run when an element is mounted to the DOM. Optionally, they can return a function that is called when the element is later removed from the DOM.
> [!NOTE]
> Attachments are available in Svelte 5.29 and newer.
```svelte
<!--- file: App.svelte --->
<script>
/** @type {import('svelte/attachments').Attachment} */
function myAttachment(element) {
console.log(element.nodeName); // 'DIV'
return () => {
console.log('cleaning up');
};
}
</script>
<div {@attach myAttachment}>...</div>
```
An element can have any number of attachments.
## Attachment factories
A useful pattern is for a function, such as `tooltip` in this example, to _return_ an attachment ([demo](/playground/untitled#H4sIAAAAAAAAE3VT0XLaMBD8lavbDiaNCUlbHhTItG_5h5AH2T5ArdBppDOEMv73SkbGJGnH47F9t3un3TsfMyO3mInsh2SW1Sa7zlZKo8_E0zHjg42pGAjxBPxp7cTvUHOMldLjv-IVGUbDoUw295VTlh-WZslqa8kxsLL2ACtHWxh175NffnQfAAGikSGxYQGfPEvGfPSIWtOH0TiBVo2pWJEBJtKhQp4YYzjG9JIdcuMM5IZqHMPioY8vOSA997zQoevf4a7heO7cdp34olRiTGr07OhwH1IdoO2A7dLMbwahZq6MbRhKZWqxk7rBxTGVbuHmhCgb5qDgmIx_J6XtHHukHTrYYqx_YpzYng8aO4RYayql7hU-1ZJl0akqHBE_D9KLolwL-Dibzc7iSln9XjtqTF1UpMkJ2EmXR-BgQErsN4pxIJKr0RVO1qrxAqaTO4fbc9bKulZm3cfDY3aZDgvFGErWjmzhN7KmfX5rXyDeX8Pt1mU-hXjdBOrtuB97vK4GPUtmJ41XcRMEGDLD8do0nJ73zhUhSlyRw0t3vPqD8cjfLs-axiFgNBrkUd9Ulp50c-GLxlXAVlJX-ffpZyiSn7H0eLCUySZQcQdXlxj4El0Yv_FZvIKElqqGTruVLhzu7VRKCh22_5toOyxsWqLwwzK-cCbYNdg-hy-p9D7sbiZWUnts_wLUOF3CJgQAAA==)):
```svelte
<!--- file: App.svelte --->
<script>
import tippy from 'tippy.js';
let content = $state('Hello!');
/**
* @param {string} content
* @returns {import('svelte/attachments').Attachment}
*/
function tooltip(content) {
return (element) => {
const tooltip = tippy(element, { content });
return tooltip.destroy;
};
}
</script>
<input bind:value={content} />
<button {@attach tooltip(content)}>
Hover me
</button>
```
Since the `tooltip(content)` expression runs inside an [effect]($effect), the attachment will be destroyed and recreated whenever `content` changes.
## Inline attachments
Attachments can also be created inline ([demo](/playground/untitled#H4sIAAAAAAAAE71WbW_aSBD-Kyt0VaBJyGKbqoUkOhdI68qGUkh6pPSDMY6xYwyH12Ab8d9vZtYE6DX38aQQe3fennlm1jvbUmTP3VKj9KcthO3MShelJz9041Ljx7YksiWKcAP2C0V9uazGazcUuDexY_d3-84iEm4kwE3pOnZW_lLcjqOx8OfLxUqwLVvafiTYjj2tFnN2Vr3yVvbUB4NqEJ81x9H11cEounbsaG3HaL_xp2J2s1WVHa5mru_NxMtyW6TAytKgwm5u2RYlYwF4YsEIVSrYDZMaVc8VLblXPlOmZ5UmxkP9P9ynJ9cR5fKxk7EIXQGQIV9wsXL_TtxY6JE_t4W_iO5wv_yURA6uWLhYLMuicrAdi_-2RAMCUGgTReUC8gUTB9mueC2WK1ckq4j9AhVytiPHDX_Fh_-PXBVvhcsdEHl7fSXZkeTHIgtdKp7c3UegUjRYjfM3hQ9ZjpOty407efbF5dyOnxssWYXlcWlqC7sBmDz3Kl575-k8bGIXvdMuvn7uKo_Zx3Ayv_Mnn-7FaH4X2Mo0m6gPyWObR5P5g2q0dc9q6fVeS8uMdifttRxvOg_DKf-ydkEHZBuj_ayZgeFZw472JfuoTb6niZPzyP78jTvtxdpUp-o0q6tWVl87c2dtBfrGan3Ip3Mn-hqkm9Ff3xbGp_6HLwqvWwOtDnFqZvAYmMPOxgyehTW8T7oZzy1fU8yhAXuW6La9xPJ5arW0fNLiWTfTamCnmsED2DlJd6BzM3DA1gBbPQVbDv444Qw6iTXgKfjxwC43B5QbyDzPgrXRNlAm0MZoW0nX5_B06Ak-Mc-k10L7kQe81M3gHvYAz0CvkTwAvC2IOdDT7kADDq0MdSHvxMp0XnAJeXyLrQCx8hTxj3J6L2Igbp5KDIRbSNw6YSPcuDfsI5ac8KI80yFWX0AeitHuox4-pa-BpoEvzKMOOSMfWDeBGIFXwP4gzOE9cu71kF_FEpgf8AF-eYq4wQZ5z8A_2HtUF_LRwjXEaYFvrBnkA7rg00L9pCfjJYjHxNzmG8qbeBlgjndBwT1ypyCG7gtPngcY-aTd8TBPM-h41vfiiX6hjsAT9g3yw4t9ReLGdR_rSjUEOfBDtQRcyKUhSI4cwG_SNlTiD3vou5XiO2IB_zniBhusJeanORnHPpLcU92oZ9F3RjUiTizkDnx2BPUv4KK6Qc9RHIwbTGPZ632vCzqjDHlxEFOK9l3C-Yx1UiQ_XDtgkjUkf0MjR59QJ5XiEqZ-geMZasBzmds9YIK-xadPfIkenTsPsWPP_YYHB2OkxXlIqT6DopYDXaOa-1i_jvwW0JkiPHhG8AwUsfpYV6gF4tFzeXYQD9ZDo76kHoV1l3r5MYa9WtG3VA-sPfYKxW5xhbiRvYm9IqhX8HwO8Ix0UL8471hLOtd16mPip4N5UR6AgRdnJ8dvCMip1vCjbw3khfFS6h9lI-jswjnHnpY16yPHWdGPGeHzMcdZTj1J_d3B_JVRjvnopCv5wD7RVdLDPqG4kscTTpQNfvPgbI3g_f-pS4--a3TGUynH_hvJb9QpDzXJg3fo3eyld1Xq3YHjmbn23lTh7sm1m3Gpwur8Df2umMq16vtlyqLF5cpdurb4zb12Gfu522Dv-HruR_IWpQGmuDdhGMILvNQQq8TdXbwyVB3NP6dT1angaKxyUxqlXuaNf40L8qKWg8-W0XV9weQdDYPXzX4YqsprvXlQpru5Dbf0kRIMSsZ-u8wvGPydeNxPTk-LFSvjlLQEY96Ex_XBXxWv_mroRp6Yoej8hmmV0wnNB7MlEK81j3dT2PXZGxnyRJKBpOyDAYkq7Pb2FsLupzips3KnoPVOY-esXFPes7csrewtYA8Eb5lli1k19qOyAAkMMLxyEsZbuW70i5MMnRR8HntxFvErXiZhguMfmL8gPOXmB3DC-E8aEafNVzVqqEGQXtdRUAcDvq6ioopSr-97tugAqvcyOar3iy3VnZLanbb1T1jZfrjxo2mp8WSHsbv7Bx1mHBBZDAAA)):
```svelte
<!--- file: App.svelte --->
<script>
import { paint } from './gradient.js';
</script>
<canvas
width={32}
height={32}
{@attach (canvas) => {
const context = canvas.getContext('2d');
$effect(() => {
let frame = requestAnimationFrame(function loop(t) {
frame = requestAnimationFrame(loop);
paint(context, t);
});
return () => {
cancelAnimationFrame(frame);
};
});
}}
></canvas>
```
## Passing attachments to components
When used on a component, `{@attach ...}` will create a prop whose key is a [`Symbol`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Symbol). If the component then [spreads](/tutorial/svelte/spread-props) props onto an element, the element will receive those attachments.
This allows you to create _wrapper components_ that augment elements ([demo](/playground/untitled#H4sIAAAAAAAAE3VUS3ObMBD-KxvajnFqsJM2PhA7TXrKob31FjITAbKtRkiMtDhJPfz3LiAMdpxhGJvdb1_fPnaeYjn3Iu-WIbJ04028lZDcetHDzsO3olbVApI74F1RhHbLJdayhFl-Sp5qhVwhufEWNjWiwJtYxSjyQhsEFEXxBiujcxg1_8O_dnQ9APwsEbVyiHDafjrvDZCgkiO4MLCEzxYZcn90z6XUZ6OxA61KlaIgV6i1pFC-sxjDrlbHaDiWRoGvdMbHsLzp5DES0mJnRxGaRBvcBHb7yFUTCQeunEWYcYtGv12TqgFUDbCK1WLaM6IWQhUlQiJUFm2ZLPly51xXMG0Rjoyd69C7UqqG2nu95QZyXvtvLVpri2-SN4hoLXXCZFfhQ8aQBU1VgdEaH_vSgyBZR_BpPp_vi0tY-rw2ulRZkGqpTQRbZvwa2BPgFC8bgbw31CbjJjAsE6WNYBZeGp7vtQXLMqHWnZx-5kM1TR5ycpkZXQR2wzL94l8Ur1C_3-g168SfQf1MyfRi3LW9fs77emJEw5QV9SREoLTq06tcczq7d6xEUcJX2vAhO1b843XK34e5unZEMBr15ekuKEusluWAF8lXhE2ZTP2r2RcIHJ-163FPKerCgYJLOB9i4GvNwviI5-gAQiFFBk3tBTOU3HFXEk0R8o86WvUD64aINhv5K3oRmpJXkw8uxMG6Hh6JY9X7OwGSqfUy9tDG3sHNoEi0d_d_fv9qndxRU0VClFqo3KVo3U655Hnt1PXB3Qra2Y2QGdEwgTAMCxopsoxOe6SD0gD8movDhT0LAnhqlE8gVCpLWnRoV7OJCkFAwEXitrYL1W7p7pbiE_P7XH6E_rihODm5s52XtiH9Ekaw0VgI9exadWL1uoEYjPtg2672k5szsxbKyWB2fdT0w5Y_0hcT8oXOlRetmLS8-g-6TLXXQgYAAA==)):
```svelte
<!--- file: Button.svelte --->
<script>
/** @type {import('svelte/elements').HTMLButtonAttributes} */
let { children, ...props } = $props();
</script>
<!-- `props` includes attachments -->
<button {...props}>
{@render children?.()}
</button>
```
```svelte
<!--- file: App.svelte --->
<script>
import tippy from 'tippy.js';
import Button from './Button.svelte';
let content = $state('Hello!');
/**
* @param {string} content
* @returns {import('svelte/attachments').Attachment}
*/
function tooltip(content) {
return (element) => {
const tooltip = tippy(element, { content });
return tooltip.destroy;
};
}
</script>
<input bind:value={content} />
<Button {@attach tooltip(content)}>
Hover me
</Button>
```
## Creating attachments programmatically
To add attachments to an object that will be spread onto a component or element, use [`createAttachmentKey`](svelte-attachments#createAttachmentKey).

@ -2,6 +2,9 @@
title: use:
---
> [!NOTE]
> In Svelte 5.29 and newer, consider using [attachments](@attach) instead, as they are more flexible and composable.
Actions are functions that are called when an element is mounted. They are added with the `use:` directive, and will typically use an `$effect` so that they can reset any state when the element is unmounted:
```svelte

@ -0,0 +1,5 @@
---
title: svelte/attachments
---
> MODULE: svelte/attachments

@ -31,6 +31,8 @@
// Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped
// TypeScript Version: 2.8
import type { Attachment } from 'svelte/attachments';
// Note: We also allow `null` as a valid value because Svelte treats this the same as `undefined`
type Booleanish = boolean | 'true' | 'false';
@ -860,6 +862,9 @@ export interface HTMLAttributes<T extends EventTarget> extends AriaAttributes, D
// allow any data- attribute
[key: `data-${string}`]: any;
// allow any attachment
[key: symbol]: Attachment<T>;
}
export type HTMLAttributeAnchorTarget = '_self' | '_blank' | '_parent' | '_top' | (string & {});

@ -34,6 +34,10 @@
"types": "./types/index.d.ts",
"default": "./src/animate/index.js"
},
"./attachments": {
"types": "./types/index.d.ts",
"default": "./src/attachments/index.js"
},
"./compiler": {
"types": "./types/index.d.ts",
"require": "./compiler/index.js",

@ -35,6 +35,7 @@ await createBundle({
[pkg.name]: `${dir}/src/index.d.ts`,
[`${pkg.name}/action`]: `${dir}/src/action/public.d.ts`,
[`${pkg.name}/animate`]: `${dir}/src/animate/public.d.ts`,
[`${pkg.name}/attachments`]: `${dir}/src/attachments/public.d.ts`,
[`${pkg.name}/compiler`]: `${dir}/src/compiler/public.d.ts`,
[`${pkg.name}/easing`]: `${dir}/src/easing/index.js`,
[`${pkg.name}/legacy`]: `${dir}/src/legacy/legacy-client.js`,

@ -0,0 +1,27 @@
import { ATTACHMENT_KEY } from '../constants.js';
/**
* Creates an object key that will be recognised as an attachment when the object is spread onto an element,
* as a programmatic alternative to using `{@attach ...}`. This can be useful for library authors, though
* is generally not needed when building an app.
*
* ```svelte
* <script>
* import { createAttachmentKey } from 'svelte/attachments';
*
* const props = {
* class: 'cool',
* onclick: () => alert('clicked'),
* [createAttachmentKey()]: (node) => {
* node.textContent = 'attached!';
* }
* };
* </script>
*
* <button {...props}>click me</button>
* ```
* @since 5.29
*/
export function createAttachmentKey() {
return Symbol(ATTACHMENT_KEY);
}

@ -0,0 +1,12 @@
/**
* An [attachment](https://svelte.dev/docs/svelte/@attach) is a function that runs when an element is mounted
* to the DOM, and optionally returns a function that is called when the element is later removed.
*
* It can be attached to an element with an `{@attach ...}` tag, or by spreading an object containing
* a property created with [`createAttachmentKey`](https://svelte.dev/docs/svelte/svelte-attachments#createAttachmentKey).
*/
export interface Attachment<T extends EventTarget = Element> {
(element: T): void | (() => void);
}
export * from './index.js';

@ -16,7 +16,7 @@ const ALLOWED_ATTRIBUTES = ['context', 'generics', 'lang', 'module'];
/**
* @param {Parser} parser
* @param {number} start
* @param {Array<AST.Attribute | AST.SpreadAttribute | AST.Directive>} attributes
* @param {Array<AST.Attribute | AST.SpreadAttribute | AST.Directive | AST.AttachTag>} attributes
* @returns {AST.Script}
*/
export function read_script(parser, start, attributes) {

@ -18,7 +18,7 @@ const REGEX_HTML_COMMENT_CLOSE = /-->/;
/**
* @param {Parser} parser
* @param {number} start
* @param {Array<AST.Attribute | AST.SpreadAttribute | AST.Directive>} attributes
* @param {Array<AST.Attribute | AST.SpreadAttribute | AST.Directive | AST.AttachTag>} attributes
* @returns {AST.CSS.StyleSheet}
*/
export default function read_style(parser, start, attributes) {

@ -480,7 +480,7 @@ function read_static_attribute(parser) {
/**
* @param {Parser} parser
* @returns {AST.Attribute | AST.SpreadAttribute | AST.Directive | null}
* @returns {AST.Attribute | AST.SpreadAttribute | AST.Directive | AST.AttachTag | null}
*/
function read_attribute(parser) {
const start = parser.index;
@ -488,6 +488,27 @@ function read_attribute(parser) {
if (parser.eat('{')) {
parser.allow_whitespace();
if (parser.eat('@attach')) {
parser.require_whitespace();
const expression = read_expression(parser);
parser.allow_whitespace();
parser.eat('}', true);
/** @type {AST.AttachTag} */
const attachment = {
type: 'AttachTag',
start,
end: parser.index,
expression,
metadata: {
expression: create_expression_metadata()
}
};
return attachment;
}
if (parser.eat('...')) {
const expression = read_expression(parser);

@ -18,6 +18,7 @@ import { extract_svelte_ignore } from '../../utils/extract_svelte_ignore.js';
import { ignore_map, ignore_stack, pop_ignore, push_ignore } from '../../state.js';
import { ArrowFunctionExpression } from './visitors/ArrowFunctionExpression.js';
import { AssignmentExpression } from './visitors/AssignmentExpression.js';
import { AttachTag } from './visitors/AttachTag.js';
import { Attribute } from './visitors/Attribute.js';
import { AwaitBlock } from './visitors/AwaitBlock.js';
import { BindDirective } from './visitors/BindDirective.js';
@ -133,6 +134,7 @@ const visitors = {
},
ArrowFunctionExpression,
AssignmentExpression,
AttachTag,
Attribute,
AwaitBlock,
BindDirective,

@ -0,0 +1,13 @@
/** @import { AST } from '#compiler' */
/** @import { Context } from '../types' */
import { mark_subtree_dynamic } from './shared/fragment.js';
/**
* @param {AST.AttachTag} node
* @param {Context} context
*/
export function AttachTag(node, context) {
mark_subtree_dynamic(context.path);
context.next({ ...context.state, expression: node.metadata.expression });
}

@ -1,3 +1,4 @@
/** @import { Expression } from 'estree' */
/** @import { AST } from '#compiler' */
/** @import { AnalysisState, Context } from '../../types' */
import * as e from '../../../../errors.js';
@ -74,7 +75,8 @@ export function visit_component(node, context) {
attribute.type !== 'SpreadAttribute' &&
attribute.type !== 'LetDirective' &&
attribute.type !== 'OnDirective' &&
attribute.type !== 'BindDirective'
attribute.type !== 'BindDirective' &&
attribute.type !== 'AttachTag'
) {
e.component_invalid_directive(attribute);
}
@ -91,15 +93,10 @@ export function visit_component(node, context) {
validate_attribute(attribute, node);
if (is_expression_attribute(attribute)) {
const expression = get_attribute_expression(attribute);
if (expression.type === 'SequenceExpression') {
let i = /** @type {number} */ (expression.start);
while (--i > 0) {
const char = context.state.analysis.source[i];
if (char === '(') break; // parenthesized sequence expressions are ok
if (char === '{') e.attribute_invalid_sequence_expression(expression);
}
}
disallow_unparenthesized_sequences(
get_attribute_expression(attribute),
context.state.analysis.source
);
}
}
@ -113,6 +110,10 @@ export function visit_component(node, context) {
if (attribute.type === 'BindDirective' && attribute.name !== 'this') {
context.state.analysis.uses_component_bindings = true;
}
if (attribute.type === 'AttachTag') {
disallow_unparenthesized_sequences(attribute.expression, context.state.analysis.source);
}
}
// If the component has a slot attribute — `<Foo slot="whatever" .../>` —
@ -158,3 +159,18 @@ export function visit_component(node, context) {
context.visit({ ...node.fragment, nodes: nodes[slot_name] }, state);
}
}
/**
* @param {Expression} expression
* @param {string} source
*/
function disallow_unparenthesized_sequences(expression, source) {
if (expression.type === 'SequenceExpression') {
let i = /** @type {number} */ (expression.start);
while (--i > 0) {
const char = source[i];
if (char === '(') break; // parenthesized sequence expressions are ok
if (char === '{') e.attribute_invalid_sequence_expression(expression);
}
}
}

@ -56,6 +56,7 @@ import { TitleElement } from './visitors/TitleElement.js';
import { TransitionDirective } from './visitors/TransitionDirective.js';
import { UpdateExpression } from './visitors/UpdateExpression.js';
import { UseDirective } from './visitors/UseDirective.js';
import { AttachTag } from './visitors/AttachTag.js';
import { VariableDeclaration } from './visitors/VariableDeclaration.js';
/** @type {Visitors} */
@ -131,6 +132,7 @@ const visitors = {
TransitionDirective,
UpdateExpression,
UseDirective,
AttachTag,
VariableDeclaration
};

@ -0,0 +1,21 @@
/** @import { Expression } from 'estree' */
/** @import { AST } from '#compiler' */
/** @import { ComponentContext } from '../types' */
import * as b from '../../../../utils/builders.js';
/**
* @param {AST.AttachTag} node
* @param {ComponentContext} context
*/
export function AttachTag(node, context) {
context.state.init.push(
b.stmt(
b.call(
'$.attach',
context.state.node,
b.thunk(/** @type {Expression} */ (context.visit(node.expression)))
)
)
);
context.next();
}

@ -83,7 +83,7 @@ export function RegularElement(node, context) {
/** @type {AST.StyleDirective[]} */
const style_directives = [];
/** @type {Array<AST.AnimateDirective | AST.BindDirective | AST.OnDirective | AST.TransitionDirective | AST.UseDirective>} */
/** @type {Array<AST.AnimateDirective | AST.BindDirective | AST.OnDirective | AST.TransitionDirective | AST.UseDirective | AST.AttachTag>} */
const other_directives = [];
/** @type {ExpressionStatement[]} */
@ -153,6 +153,10 @@ export function RegularElement(node, context) {
has_use = true;
other_directives.push(attribute);
break;
case 'AttachTag':
other_directives.push(attribute);
break;
}
}

@ -257,6 +257,14 @@ export function build_component(node, component_name, context, anchor = context.
);
}
}
} else if (attribute.type === 'AttachTag') {
let expression = /** @type {Expression} */ (context.visit(attribute.expression));
if (attribute.metadata.expression.has_state) {
expression = b.arrow([b.id('$$node')], b.call(expression, b.id('$$node')));
}
push_prop(b.prop('get', b.call('$.attachment'), expression, true));
}
}

@ -174,6 +174,16 @@ export namespace AST {
};
}
/** A `{@attach foo(...)} tag */
export interface AttachTag extends BaseNode {
type: 'AttachTag';
expression: Expression;
/** @internal */
metadata: {
expression: ExpressionMetadata;
};
}
/** An `animate:` directive */
export interface AnimateDirective extends BaseNode {
type: 'AnimateDirective';
@ -273,7 +283,7 @@ export namespace AST {
interface BaseElement extends BaseNode {
name: string;
attributes: Array<Attribute | SpreadAttribute | Directive>;
attributes: Array<Attribute | SpreadAttribute | Directive | AttachTag>;
fragment: Fragment;
}
@ -546,6 +556,7 @@ export namespace AST {
| AST.Attribute
| AST.SpreadAttribute
| Directive
| AST.AttachTag
| AST.Comment
| Block;

@ -55,3 +55,5 @@ export const IGNORABLE_RUNTIME_WARNINGS = /** @type {const} */ ([
* TODO this is currently unused
*/
export const ELEMENTS_WITHOUT_TEXT = ['audio', 'datalist', 'dl', 'optgroup', 'select', 'video'];
export const ATTACHMENT_KEY = '@attach';

@ -0,0 +1,15 @@
import { effect } from '../../reactivity/effects.js';
/**
* @param {Element} node
* @param {() => (node: Element) => void} get_fn
*/
export function attach(node, get_fn) {
effect(() => {
const fn = get_fn();
// we use `&&` rather than `?.` so that things like
// `{@attach DEV && something_dev_only()}` work
return fn && fn(node);
});
}

@ -13,10 +13,11 @@ import {
set_active_effect,
set_active_reaction
} from '../../runtime.js';
import { attach } from './attachments.js';
import { clsx } from '../../../shared/attributes.js';
import { set_class } from './class.js';
import { set_style } from './style.js';
import { NAMESPACE_HTML } from '../../../../constants.js';
import { ATTACHMENT_KEY, NAMESPACE_HTML } from '../../../../constants.js';
export const CLASS = Symbol('class');
export const STYLE = Symbol('style');
@ -446,6 +447,12 @@ export function set_attributes(element, prev, next, css_hash, skip_warning = fal
set_hydrating(true);
}
for (let symbol of Object.getOwnPropertySymbols(next)) {
if (symbol.description === ATTACHMENT_KEY) {
attach(element, () => next[symbol]);
}
}
return current;
}

@ -1,3 +1,4 @@
export { createAttachmentKey as attachment } from '../../attachments/index.js';
export { FILENAME, HMR, NAMESPACE_SVG } from '../../constants.js';
export { push, pop } from './context.js';
export { assign, assign_and, assign_or, assign_nullish } from './dev/assign.js';
@ -22,6 +23,7 @@ export { element } from './dom/blocks/svelte-element.js';
export { head } from './dom/blocks/svelte-head.js';
export { append_styles } from './dom/css.js';
export { action } from './dom/elements/actions.js';
export { attach } from './dom/elements/attachments.js';
export {
remove_input_defaults,
set_attribute,

@ -218,9 +218,15 @@ const spread_props_handler = {
for (let p of target.props) {
if (is_function(p)) p = p();
if (!p) continue;
for (const key in p) {
if (!keys.includes(key)) keys.push(key);
}
for (const key of Object.getOwnPropertySymbols(p)) {
if (!keys.includes(key)) keys.push(key);
}
}
return keys;

@ -0,0 +1 @@
<div {@attach (node) => {}} {@attach (node) => {}}></div>

@ -0,0 +1,141 @@
{
"css": null,
"js": [],
"start": 0,
"end": 57,
"type": "Root",
"fragment": {
"type": "Fragment",
"nodes": [
{
"type": "RegularElement",
"start": 0,
"end": 57,
"name": "div",
"attributes": [
{
"type": "AttachTag",
"start": 5,
"end": 27,
"expression": {
"type": "ArrowFunctionExpression",
"start": 14,
"end": 26,
"loc": {
"start": {
"line": 1,
"column": 14
},
"end": {
"line": 1,
"column": 26
}
},
"id": null,
"expression": false,
"generator": false,
"async": false,
"params": [
{
"type": "Identifier",
"start": 15,
"end": 19,
"loc": {
"start": {
"line": 1,
"column": 15
},
"end": {
"line": 1,
"column": 19
}
},
"name": "node"
}
],
"body": {
"type": "BlockStatement",
"start": 24,
"end": 26,
"loc": {
"start": {
"line": 1,
"column": 24
},
"end": {
"line": 1,
"column": 26
}
},
"body": []
}
}
},
{
"type": "AttachTag",
"start": 28,
"end": 50,
"expression": {
"type": "ArrowFunctionExpression",
"start": 37,
"end": 49,
"loc": {
"start": {
"line": 1,
"column": 37
},
"end": {
"line": 1,
"column": 49
}
},
"id": null,
"expression": false,
"generator": false,
"async": false,
"params": [
{
"type": "Identifier",
"start": 38,
"end": 42,
"loc": {
"start": {
"line": 1,
"column": 38
},
"end": {
"line": 1,
"column": 42
}
},
"name": "node"
}
],
"body": {
"type": "BlockStatement",
"start": 47,
"end": 49,
"loc": {
"start": {
"line": 1,
"column": 47
},
"end": {
"line": 1,
"column": 49
}
},
"body": []
}
}
}
],
"fragment": {
"type": "Fragment",
"nodes": []
}
}
]
},
"options": null
}

@ -0,0 +1,6 @@
import { test } from '../../test';
export default test({
ssrHtml: `<div></div>`,
html: `<div>DIV</div>`
});

@ -0,0 +1 @@
<div {@attach (node) => node.textContent = node.nodeName}></div>

@ -0,0 +1,5 @@
<script>
let props = $props();
</script>
<div {...props}></div>

@ -0,0 +1,15 @@
import { flushSync } from 'svelte';
import { test } from '../../test';
export default test({
html: `<button>update</button><div></div>`,
test({ target, assert, logs }) {
const button = target.querySelector('button');
assert.deepEqual(logs, ['one DIV']);
flushSync(() => button?.click());
assert.deepEqual(logs, ['one DIV', 'cleanup one', 'two DIV']);
}
});

@ -0,0 +1,30 @@
<script>
import { createAttachmentKey } from 'svelte/attachments';
import Child from './Child.svelte';
let stuff = $state({
[createAttachmentKey()]: (node) => {
console.log(`one ${node.nodeName}`);
return () => {
console.log('cleanup one');
};
}
});
function update() {
stuff = {
[createAttachmentKey()]: (node) => {
console.log(`two ${node.nodeName}`);
return () => {
console.log('cleanup two');
}
}
};
}
</script>
<button onclick={update}>update</button>
<Child {...stuff} />

@ -0,0 +1,5 @@
<script>
let props = $props();
</script>
<div {...props}></div>

@ -0,0 +1,14 @@
import { flushSync } from 'svelte';
import { test } from '../../test';
export default test({
ssrHtml: `<button>update</button><div></div>`,
html: `<button>update</button><div>one</div>`,
test({ target, assert }) {
const button = target.querySelector('button');
flushSync(() => button?.click());
assert.htmlEqual(target.innerHTML, '<button>update</button><div>two</div>');
}
});

@ -0,0 +1,15 @@
<script>
import Child from './Child.svelte';
let message = $state('one');
function attachment(message) {
return (node) => {
node.textContent = message;
};
}
</script>
<button onclick={() => message = 'two'}>update</button>
<Child {@attach attachment(message)} />

@ -0,0 +1,14 @@
import { flushSync } from 'svelte';
import { test } from '../../test';
export default test({
ssrHtml: `<div></div><button>increment</button>`,
html: `<div>1</div><button>increment</button>`,
test: ({ assert, target }) => {
const btn = target.querySelector('button');
flushSync(() => btn?.click());
assert.htmlEqual(target.innerHTML, `<div>2</div><button>increment</button>`);
}
});

@ -0,0 +1,6 @@
<script>
let value = $state(1);
</script>
<div {@attach (node) => node.textContent = value}></div>
<button onclick={() => value += 1}>increment</button>

@ -0,0 +1,8 @@
import { flushSync } from 'svelte';
import { test } from '../../test';
export default test({
test({ assert, logs, target }) {
assert.deepEqual(logs, ['hello']);
}
});

@ -0,0 +1,9 @@
<script>
import { createAttachmentKey } from 'svelte/attachments';
let stuff = $state({
[createAttachmentKey()]: () => console.log('hello')
});
</script>
<div {...stuff}></div>

@ -0,0 +1,6 @@
import { test } from '../../test';
export default test({
ssrHtml: `<div></div>`,
html: `<div>DIV</div>`
});

@ -0,0 +1 @@
<svelte:element this={'div'} {@attach (node) => node.textContent = node.nodeName}></svelte:element>

@ -624,6 +624,44 @@ declare module 'svelte/animate' {
export {};
}
declare module 'svelte/attachments' {
/**
* An [attachment](https://svelte.dev/docs/svelte/@attach) is a function that runs when an element is mounted
* to the DOM, and optionally returns a function that is called when the element is later removed.
*
* It can be attached to an element with an `{@attach ...}` tag, or by spreading an object containing
* a property created with [`createAttachmentKey`](https://svelte.dev/docs/svelte/svelte-attachments#createAttachmentKey).
*/
export interface Attachment<T extends EventTarget = Element> {
(element: T): void | (() => void);
}
/**
* Creates an object key that will be recognised as an attachment when the object is spread onto an element,
* as a programmatic alternative to using `{@attach ...}`. This can be useful for library authors, though
* is generally not needed when building an app.
*
* ```svelte
* <script>
* import { createAttachmentKey } from 'svelte/attachments';
*
* const props = {
* class: 'cool',
* onclick: () => alert('clicked'),
* [createAttachmentKey()]: (node) => {
* node.textContent = 'attached!';
* }
* };
* </script>
*
* <button {...props}>click me</button>
* ```
* @since 5.29
*/
export function createAttachmentKey(): symbol;
export {};
}
declare module 'svelte/compiler' {
import type { SourceMap } from 'magic-string';
import type { ArrayExpression, ArrowFunctionExpression, VariableDeclaration, VariableDeclarator, Expression, Identifier, MemberExpression, Node, ObjectExpression, Pattern, Program, ChainExpression, SimpleCallExpression, SequenceExpression } from 'estree';
@ -1055,6 +1093,12 @@ declare module 'svelte/compiler' {
expression: SimpleCallExpression | (ChainExpression & { expression: SimpleCallExpression });
}
/** A `{@attach foo(...)} tag */
export interface AttachTag extends BaseNode {
type: 'AttachTag';
expression: Expression;
}
/** An `animate:` directive */
export interface AnimateDirective extends BaseNode {
type: 'AnimateDirective';
@ -1137,7 +1181,7 @@ declare module 'svelte/compiler' {
interface BaseElement extends BaseNode {
name: string;
attributes: Array<Attribute | SpreadAttribute | Directive>;
attributes: Array<Attribute | SpreadAttribute | Directive | AttachTag>;
fragment: Fragment;
}
@ -1327,6 +1371,7 @@ declare module 'svelte/compiler' {
| AST.Attribute
| AST.SpreadAttribute
| Directive
| AST.AttachTag
| AST.Comment
| Block;

Loading…
Cancel
Save