diff --git a/.changeset/poor-days-pay.md b/.changeset/poor-days-pay.md
new file mode 100644
index 000000000000..8fbff1058686
--- /dev/null
+++ b/.changeset/poor-days-pay.md
@@ -0,0 +1,5 @@
+---
+'svelte': minor
+---
+
+feat: attachments
diff --git a/documentation/docs/03-template-syntax/09-@attach.md b/documentation/docs/03-template-syntax/09-@attach.md
new file mode 100644
index 000000000000..c988386933aa
--- /dev/null
+++ b/documentation/docs/03-template-syntax/09-@attach.md
@@ -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
+
+
+
+
...
+```
+
+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
+
+
+
+
+
+
+```
+
+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
+
+
+
+
+```
+
+## 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
+
+
+
+
+
+```
+
+```svelte
+
+
+
+
+
+
+```
+
+## Creating attachments programmatically
+
+To add attachments to an object that will be spread onto a component or element, use [`createAttachmentKey`](svelte-attachments#createAttachmentKey).
diff --git a/documentation/docs/03-template-syntax/09-@const.md b/documentation/docs/03-template-syntax/10-@const.md
similarity index 100%
rename from documentation/docs/03-template-syntax/09-@const.md
rename to documentation/docs/03-template-syntax/10-@const.md
diff --git a/documentation/docs/03-template-syntax/10-@debug.md b/documentation/docs/03-template-syntax/11-@debug.md
similarity index 100%
rename from documentation/docs/03-template-syntax/10-@debug.md
rename to documentation/docs/03-template-syntax/11-@debug.md
diff --git a/documentation/docs/03-template-syntax/11-bind.md b/documentation/docs/03-template-syntax/12-bind.md
similarity index 100%
rename from documentation/docs/03-template-syntax/11-bind.md
rename to documentation/docs/03-template-syntax/12-bind.md
diff --git a/documentation/docs/03-template-syntax/12-use.md b/documentation/docs/03-template-syntax/13-use.md
similarity index 93%
rename from documentation/docs/03-template-syntax/12-use.md
rename to documentation/docs/03-template-syntax/13-use.md
index 45de0235782b..5f5321a1c09f 100644
--- a/documentation/docs/03-template-syntax/12-use.md
+++ b/documentation/docs/03-template-syntax/13-use.md
@@ -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
diff --git a/documentation/docs/03-template-syntax/13-transition.md b/documentation/docs/03-template-syntax/14-transition.md
similarity index 100%
rename from documentation/docs/03-template-syntax/13-transition.md
rename to documentation/docs/03-template-syntax/14-transition.md
diff --git a/documentation/docs/03-template-syntax/14-in-and-out.md b/documentation/docs/03-template-syntax/15-in-and-out.md
similarity index 100%
rename from documentation/docs/03-template-syntax/14-in-and-out.md
rename to documentation/docs/03-template-syntax/15-in-and-out.md
diff --git a/documentation/docs/03-template-syntax/15-animate.md b/documentation/docs/03-template-syntax/16-animate.md
similarity index 100%
rename from documentation/docs/03-template-syntax/15-animate.md
rename to documentation/docs/03-template-syntax/16-animate.md
diff --git a/documentation/docs/98-reference/21-svelte-attachments.md b/documentation/docs/98-reference/21-svelte-attachments.md
new file mode 100644
index 000000000000..e446bdddc2dc
--- /dev/null
+++ b/documentation/docs/98-reference/21-svelte-attachments.md
@@ -0,0 +1,5 @@
+---
+title: svelte/attachments
+---
+
+> MODULE: svelte/attachments
diff --git a/packages/svelte/elements.d.ts b/packages/svelte/elements.d.ts
index 99d87b4c09a4..c63713736594 100644
--- a/packages/svelte/elements.d.ts
+++ b/packages/svelte/elements.d.ts
@@ -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 extends AriaAttributes, D
// allow any data- attribute
[key: `data-${string}`]: any;
+
+ // allow any attachment
+ [key: symbol]: Attachment;
}
export type HTMLAttributeAnchorTarget = '_self' | '_blank' | '_parent' | '_top' | (string & {});
diff --git a/packages/svelte/package.json b/packages/svelte/package.json
index dadba9d50ce2..a4a970502f38 100644
--- a/packages/svelte/package.json
+++ b/packages/svelte/package.json
@@ -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",
diff --git a/packages/svelte/scripts/generate-types.js b/packages/svelte/scripts/generate-types.js
index 377fca434388..c558a2bbf78a 100644
--- a/packages/svelte/scripts/generate-types.js
+++ b/packages/svelte/scripts/generate-types.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`,
diff --git a/packages/svelte/src/attachments/index.js b/packages/svelte/src/attachments/index.js
new file mode 100644
index 000000000000..948a19f4dd4b
--- /dev/null
+++ b/packages/svelte/src/attachments/index.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
+ *
+ *
+ *
+ * ```
+ * @since 5.29
+ */
+export function createAttachmentKey() {
+ return Symbol(ATTACHMENT_KEY);
+}
diff --git a/packages/svelte/src/attachments/public.d.ts b/packages/svelte/src/attachments/public.d.ts
new file mode 100644
index 000000000000..caf1342d0a09
--- /dev/null
+++ b/packages/svelte/src/attachments/public.d.ts
@@ -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 {
+ (element: T): void | (() => void);
+}
+
+export * from './index.js';
diff --git a/packages/svelte/src/compiler/phases/1-parse/read/script.js b/packages/svelte/src/compiler/phases/1-parse/read/script.js
index 9d9ed3a1efdf..629012781188 100644
--- a/packages/svelte/src/compiler/phases/1-parse/read/script.js
+++ b/packages/svelte/src/compiler/phases/1-parse/read/script.js
@@ -16,7 +16,7 @@ const ALLOWED_ATTRIBUTES = ['context', 'generics', 'lang', 'module'];
/**
* @param {Parser} parser
* @param {number} start
- * @param {Array} attributes
+ * @param {Array} attributes
* @returns {AST.Script}
*/
export function read_script(parser, start, attributes) {
diff --git a/packages/svelte/src/compiler/phases/1-parse/read/style.js b/packages/svelte/src/compiler/phases/1-parse/read/style.js
index 56dbe124b7bf..e15a47e6d589 100644
--- a/packages/svelte/src/compiler/phases/1-parse/read/style.js
+++ b/packages/svelte/src/compiler/phases/1-parse/read/style.js
@@ -18,7 +18,7 @@ const REGEX_HTML_COMMENT_CLOSE = /-->/;
/**
* @param {Parser} parser
* @param {number} start
- * @param {Array} attributes
+ * @param {Array} attributes
* @returns {AST.CSS.StyleSheet}
*/
export default function read_style(parser, start, attributes) {
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 66946a8f8d22..d20369b0d984 100644
--- a/packages/svelte/src/compiler/phases/1-parse/state/element.js
+++ b/packages/svelte/src/compiler/phases/1-parse/state/element.js
@@ -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);
diff --git a/packages/svelte/src/compiler/phases/2-analyze/index.js b/packages/svelte/src/compiler/phases/2-analyze/index.js
index 766b317d06e2..09cb41b23f5c 100644
--- a/packages/svelte/src/compiler/phases/2-analyze/index.js
+++ b/packages/svelte/src/compiler/phases/2-analyze/index.js
@@ -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,
diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/AttachTag.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/AttachTag.js
new file mode 100644
index 000000000000..1e318f228d8b
--- /dev/null
+++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/AttachTag.js
@@ -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 });
+}
diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/component.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/component.js
index 04bf3d2ff3bf..aca87fab811c 100644
--- a/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/component.js
+++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/component.js
@@ -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 — `` —
@@ -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);
+ }
+ }
+}
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 f0da5a491887..9a2f4dd34c70 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
@@ -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
};
diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/AttachTag.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/AttachTag.js
new file mode 100644
index 000000000000..062604cacc16
--- /dev/null
+++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/AttachTag.js
@@ -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();
+}
diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js
index 7468fcbbc72e..ab981878ad78 100644
--- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js
+++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js
@@ -83,7 +83,7 @@ export function RegularElement(node, context) {
/** @type {AST.StyleDirective[]} */
const style_directives = [];
- /** @type {Array} */
+ /** @type {Array} */
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;
}
}
diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/component.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/component.js
index c4071c67fe6c..6d3d8a68e68e 100644
--- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/component.js
+++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/component.js
@@ -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));
}
}
diff --git a/packages/svelte/src/compiler/types/template.d.ts b/packages/svelte/src/compiler/types/template.d.ts
index a544cd1dec09..54fdda92b845 100644
--- a/packages/svelte/src/compiler/types/template.d.ts
+++ b/packages/svelte/src/compiler/types/template.d.ts
@@ -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;
+ attributes: Array;
fragment: Fragment;
}
@@ -546,6 +556,7 @@ export namespace AST {
| AST.Attribute
| AST.SpreadAttribute
| Directive
+ | AST.AttachTag
| AST.Comment
| Block;
diff --git a/packages/svelte/src/constants.js b/packages/svelte/src/constants.js
index 8861e440fc30..2ecd4afee2bf 100644
--- a/packages/svelte/src/constants.js
+++ b/packages/svelte/src/constants.js
@@ -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';
diff --git a/packages/svelte/src/internal/client/dom/elements/attachments.js b/packages/svelte/src/internal/client/dom/elements/attachments.js
new file mode 100644
index 000000000000..6e3089a384c1
--- /dev/null
+++ b/packages/svelte/src/internal/client/dom/elements/attachments.js
@@ -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);
+ });
+}
diff --git a/packages/svelte/src/internal/client/dom/elements/attributes.js b/packages/svelte/src/internal/client/dom/elements/attributes.js
index f63f55cc6ee6..3d1acbd31ce1 100644
--- a/packages/svelte/src/internal/client/dom/elements/attributes.js
+++ b/packages/svelte/src/internal/client/dom/elements/attributes.js
@@ -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;
}
diff --git a/packages/svelte/src/internal/client/index.js b/packages/svelte/src/internal/client/index.js
index 14d6e29f5bb4..b5f746b276bf 100644
--- a/packages/svelte/src/internal/client/index.js
+++ b/packages/svelte/src/internal/client/index.js
@@ -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,
diff --git a/packages/svelte/src/internal/client/reactivity/props.js b/packages/svelte/src/internal/client/reactivity/props.js
index 8bfd8f9e25d1..77c58720e128 100644
--- a/packages/svelte/src/internal/client/reactivity/props.js
+++ b/packages/svelte/src/internal/client/reactivity/props.js
@@ -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;
diff --git a/packages/svelte/tests/parser-modern/samples/attachments/input.svelte b/packages/svelte/tests/parser-modern/samples/attachments/input.svelte
new file mode 100644
index 000000000000..9faae8d1bf40
--- /dev/null
+++ b/packages/svelte/tests/parser-modern/samples/attachments/input.svelte
@@ -0,0 +1 @@
+
+
diff --git a/packages/svelte/tests/runtime-runes/samples/attachment-spread/_config.js b/packages/svelte/tests/runtime-runes/samples/attachment-spread/_config.js
new file mode 100644
index 000000000000..96fc20745025
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/attachment-spread/_config.js
@@ -0,0 +1,8 @@
+import { flushSync } from 'svelte';
+import { test } from '../../test';
+
+export default test({
+ test({ assert, logs, target }) {
+ assert.deepEqual(logs, ['hello']);
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/attachment-spread/main.svelte b/packages/svelte/tests/runtime-runes/samples/attachment-spread/main.svelte
new file mode 100644
index 000000000000..dbd8c47ada1e
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/attachment-spread/main.svelte
@@ -0,0 +1,9 @@
+
+
+
diff --git a/packages/svelte/tests/runtime-runes/samples/attachment-svelte-element/_config.js b/packages/svelte/tests/runtime-runes/samples/attachment-svelte-element/_config.js
new file mode 100644
index 000000000000..1be47370691a
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/attachment-svelte-element/_config.js
@@ -0,0 +1,6 @@
+import { test } from '../../test';
+
+export default test({
+ ssrHtml: ``,
+ html: `
DIV
`
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/attachment-svelte-element/main.svelte b/packages/svelte/tests/runtime-runes/samples/attachment-svelte-element/main.svelte
new file mode 100644
index 000000000000..bd4b52342f32
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/attachment-svelte-element/main.svelte
@@ -0,0 +1 @@
+ node.textContent = node.nodeName}>
diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts
index b233cfcc0b58..ff9764b88b1a 100644
--- a/packages/svelte/types/index.d.ts
+++ b/packages/svelte/types/index.d.ts
@@ -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 {
+ (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
+ *
+ *
+ *
+ * ```
+ * @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;
+ attributes: Array;
fragment: Fragment;
}
@@ -1327,6 +1371,7 @@ declare module 'svelte/compiler' {
| AST.Attribute
| AST.SpreadAttribute
| Directive
+ | AST.AttachTag
| AST.Comment
| Block;