Skip to content

Commit b93a617

Browse files
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 <[email protected]> * 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 <[email protected]>
1 parent 7636031 commit b93a617

File tree

48 files changed

+669
-17
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

48 files changed

+669
-17
lines changed

.changeset/poor-days-pay.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'svelte': minor
3+
---
4+
5+
feat: attachments
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
---
2+
title: {@attach ...}
3+
---
4+
5+
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.
6+
7+
> [!NOTE]
8+
> Attachments are available in Svelte 5.29 and newer.
9+
10+
```svelte
11+
<!--- file: App.svelte --->
12+
<script>
13+
/** @type {import('svelte/attachments').Attachment} */
14+
function myAttachment(element) {
15+
console.log(element.nodeName); // 'DIV'
16+
17+
return () => {
18+
console.log('cleaning up');
19+
};
20+
}
21+
</script>
22+
23+
<div {@attach myAttachment}>...</div>
24+
```
25+
26+
An element can have any number of attachments.
27+
28+
## Attachment factories
29+
30+
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==)):
31+
32+
```svelte
33+
<!--- file: App.svelte --->
34+
<script>
35+
import tippy from 'tippy.js';
36+
37+
let content = $state('Hello!');
38+
39+
/**
40+
* @param {string} content
41+
* @returns {import('svelte/attachments').Attachment}
42+
*/
43+
function tooltip(content) {
44+
return (element) => {
45+
const tooltip = tippy(element, { content });
46+
return tooltip.destroy;
47+
};
48+
}
49+
</script>
50+
51+
<input bind:value={content} />
52+
53+
<button {@attach tooltip(content)}>
54+
Hover me
55+
</button>
56+
```
57+
58+
Since the `tooltip(content)` expression runs inside an [effect]($effect), the attachment will be destroyed and recreated whenever `content` changes.
59+
60+
## Inline attachments
61+
62+
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)):
63+
64+
```svelte
65+
<!--- file: App.svelte --->
66+
<script>
67+
import { paint } from './gradient.js';
68+
</script>
69+
70+
<canvas
71+
width={32}
72+
height={32}
73+
{@attach (canvas) => {
74+
const context = canvas.getContext('2d');
75+
76+
$effect(() => {
77+
let frame = requestAnimationFrame(function loop(t) {
78+
frame = requestAnimationFrame(loop);
79+
paint(context, t);
80+
});
81+
82+
return () => {
83+
cancelAnimationFrame(frame);
84+
};
85+
});
86+
}}
87+
></canvas>
88+
```
89+
90+
## Passing attachments to components
91+
92+
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.
93+
94+
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==)):
95+
96+
```svelte
97+
<!--- file: Button.svelte --->
98+
<script>
99+
/** @type {import('svelte/elements').HTMLButtonAttributes} */
100+
let { children, ...props } = $props();
101+
</script>
102+
103+
<!-- `props` includes attachments -->
104+
<button {...props}>
105+
{@render children?.()}
106+
</button>
107+
```
108+
109+
```svelte
110+
<!--- file: App.svelte --->
111+
<script>
112+
import tippy from 'tippy.js';
113+
import Button from './Button.svelte';
114+
115+
let content = $state('Hello!');
116+
117+
/**
118+
* @param {string} content
119+
* @returns {import('svelte/attachments').Attachment}
120+
*/
121+
function tooltip(content) {
122+
return (element) => {
123+
const tooltip = tippy(element, { content });
124+
return tooltip.destroy;
125+
};
126+
}
127+
</script>
128+
129+
<input bind:value={content} />
130+
131+
<Button {@attach tooltip(content)}>
132+
Hover me
133+
</Button>
134+
```
135+
136+
## Creating attachments programmatically
137+
138+
To add attachments to an object that will be spread onto a component or element, use [`createAttachmentKey`](svelte-attachments#createAttachmentKey).

documentation/docs/03-template-syntax/12-use.md renamed to documentation/docs/03-template-syntax/13-use.md

+3
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
title: use:
33
---
44

5+
> [!NOTE]
6+
> In Svelte 5.29 and newer, consider using [attachments](@attach) instead, as they are more flexible and composable.
7+
58
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:
69

710
```svelte
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
title: svelte/attachments
3+
---
4+
5+
> MODULE: svelte/attachments

packages/svelte/elements.d.ts

+5
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@
3131
// Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped
3232
// TypeScript Version: 2.8
3333

34+
import type { Attachment } from 'svelte/attachments';
35+
3436
// Note: We also allow `null` as a valid value because Svelte treats this the same as `undefined`
3537

3638
type Booleanish = boolean | 'true' | 'false';
@@ -860,6 +862,9 @@ export interface HTMLAttributes<T extends EventTarget> extends AriaAttributes, D
860862

861863
// allow any data- attribute
862864
[key: `data-${string}`]: any;
865+
866+
// allow any attachment
867+
[key: symbol]: Attachment<T>;
863868
}
864869

865870
export type HTMLAttributeAnchorTarget = '_self' | '_blank' | '_parent' | '_top' | (string & {});

packages/svelte/package.json

+4
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@
3434
"types": "./types/index.d.ts",
3535
"default": "./src/animate/index.js"
3636
},
37+
"./attachments": {
38+
"types": "./types/index.d.ts",
39+
"default": "./src/attachments/index.js"
40+
},
3741
"./compiler": {
3842
"types": "./types/index.d.ts",
3943
"require": "./compiler/index.js",

packages/svelte/scripts/generate-types.js

+1
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ await createBundle({
3535
[pkg.name]: `${dir}/src/index.d.ts`,
3636
[`${pkg.name}/action`]: `${dir}/src/action/public.d.ts`,
3737
[`${pkg.name}/animate`]: `${dir}/src/animate/public.d.ts`,
38+
[`${pkg.name}/attachments`]: `${dir}/src/attachments/public.d.ts`,
3839
[`${pkg.name}/compiler`]: `${dir}/src/compiler/public.d.ts`,
3940
[`${pkg.name}/easing`]: `${dir}/src/easing/index.js`,
4041
[`${pkg.name}/legacy`]: `${dir}/src/legacy/legacy-client.js`,
+27
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { ATTACHMENT_KEY } from '../constants.js';
2+
3+
/**
4+
* Creates an object key that will be recognised as an attachment when the object is spread onto an element,
5+
* as a programmatic alternative to using `{@attach ...}`. This can be useful for library authors, though
6+
* is generally not needed when building an app.
7+
*
8+
* ```svelte
9+
* <script>
10+
* import { createAttachmentKey } from 'svelte/attachments';
11+
*
12+
* const props = {
13+
* class: 'cool',
14+
* onclick: () => alert('clicked'),
15+
* [createAttachmentKey()]: (node) => {
16+
* node.textContent = 'attached!';
17+
* }
18+
* };
19+
* </script>
20+
*
21+
* <button {...props}>click me</button>
22+
* ```
23+
* @since 5.29
24+
*/
25+
export function createAttachmentKey() {
26+
return Symbol(ATTACHMENT_KEY);
27+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/**
2+
* An [attachment](https://svelte.dev/docs/svelte/@attach) is a function that runs when an element is mounted
3+
* to the DOM, and optionally returns a function that is called when the element is later removed.
4+
*
5+
* It can be attached to an element with an `{@attach ...}` tag, or by spreading an object containing
6+
* a property created with [`createAttachmentKey`](https://svelte.dev/docs/svelte/svelte-attachments#createAttachmentKey).
7+
*/
8+
export interface Attachment<T extends EventTarget = Element> {
9+
(element: T): void | (() => void);
10+
}
11+
12+
export * from './index.js';

packages/svelte/src/compiler/phases/1-parse/read/script.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ const ALLOWED_ATTRIBUTES = ['context', 'generics', 'lang', 'module'];
1616
/**
1717
* @param {Parser} parser
1818
* @param {number} start
19-
* @param {Array<AST.Attribute | AST.SpreadAttribute | AST.Directive>} attributes
19+
* @param {Array<AST.Attribute | AST.SpreadAttribute | AST.Directive | AST.AttachTag>} attributes
2020
* @returns {AST.Script}
2121
*/
2222
export function read_script(parser, start, attributes) {

packages/svelte/src/compiler/phases/1-parse/read/style.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ const REGEX_HTML_COMMENT_CLOSE = /-->/;
1818
/**
1919
* @param {Parser} parser
2020
* @param {number} start
21-
* @param {Array<AST.Attribute | AST.SpreadAttribute | AST.Directive>} attributes
21+
* @param {Array<AST.Attribute | AST.SpreadAttribute | AST.Directive | AST.AttachTag>} attributes
2222
* @returns {AST.CSS.StyleSheet}
2323
*/
2424
export default function read_style(parser, start, attributes) {

packages/svelte/src/compiler/phases/1-parse/state/element.js

+22-1
Original file line numberDiff line numberDiff line change
@@ -480,14 +480,35 @@ function read_static_attribute(parser) {
480480

481481
/**
482482
* @param {Parser} parser
483-
* @returns {AST.Attribute | AST.SpreadAttribute | AST.Directive | null}
483+
* @returns {AST.Attribute | AST.SpreadAttribute | AST.Directive | AST.AttachTag | null}
484484
*/
485485
function read_attribute(parser) {
486486
const start = parser.index;
487487

488488
if (parser.eat('{')) {
489489
parser.allow_whitespace();
490490

491+
if (parser.eat('@attach')) {
492+
parser.require_whitespace();
493+
494+
const expression = read_expression(parser);
495+
parser.allow_whitespace();
496+
parser.eat('}', true);
497+
498+
/** @type {AST.AttachTag} */
499+
const attachment = {
500+
type: 'AttachTag',
501+
start,
502+
end: parser.index,
503+
expression,
504+
metadata: {
505+
expression: create_expression_metadata()
506+
}
507+
};
508+
509+
return attachment;
510+
}
511+
491512
if (parser.eat('...')) {
492513
const expression = read_expression(parser);
493514

packages/svelte/src/compiler/phases/2-analyze/index.js

+2
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { extract_svelte_ignore } from '../../utils/extract_svelte_ignore.js';
1818
import { ignore_map, ignore_stack, pop_ignore, push_ignore } from '../../state.js';
1919
import { ArrowFunctionExpression } from './visitors/ArrowFunctionExpression.js';
2020
import { AssignmentExpression } from './visitors/AssignmentExpression.js';
21+
import { AttachTag } from './visitors/AttachTag.js';
2122
import { Attribute } from './visitors/Attribute.js';
2223
import { AwaitBlock } from './visitors/AwaitBlock.js';
2324
import { BindDirective } from './visitors/BindDirective.js';
@@ -133,6 +134,7 @@ const visitors = {
133134
},
134135
ArrowFunctionExpression,
135136
AssignmentExpression,
137+
AttachTag,
136138
Attribute,
137139
AwaitBlock,
138140
BindDirective,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/** @import { AST } from '#compiler' */
2+
/** @import { Context } from '../types' */
3+
4+
import { mark_subtree_dynamic } from './shared/fragment.js';
5+
6+
/**
7+
* @param {AST.AttachTag} node
8+
* @param {Context} context
9+
*/
10+
export function AttachTag(node, context) {
11+
mark_subtree_dynamic(context.path);
12+
context.next({ ...context.state, expression: node.metadata.expression });
13+
}

packages/svelte/src/compiler/phases/2-analyze/visitors/shared/component.js

+26-10
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
/** @import { Expression } from 'estree' */
12
/** @import { AST } from '#compiler' */
23
/** @import { AnalysisState, Context } from '../../types' */
34
import * as e from '../../../../errors.js';
@@ -74,7 +75,8 @@ export function visit_component(node, context) {
7475
attribute.type !== 'SpreadAttribute' &&
7576
attribute.type !== 'LetDirective' &&
7677
attribute.type !== 'OnDirective' &&
77-
attribute.type !== 'BindDirective'
78+
attribute.type !== 'BindDirective' &&
79+
attribute.type !== 'AttachTag'
7880
) {
7981
e.component_invalid_directive(attribute);
8082
}
@@ -91,15 +93,10 @@ export function visit_component(node, context) {
9193
validate_attribute(attribute, node);
9294

9395
if (is_expression_attribute(attribute)) {
94-
const expression = get_attribute_expression(attribute);
95-
if (expression.type === 'SequenceExpression') {
96-
let i = /** @type {number} */ (expression.start);
97-
while (--i > 0) {
98-
const char = context.state.analysis.source[i];
99-
if (char === '(') break; // parenthesized sequence expressions are ok
100-
if (char === '{') e.attribute_invalid_sequence_expression(expression);
101-
}
102-
}
96+
disallow_unparenthesized_sequences(
97+
get_attribute_expression(attribute),
98+
context.state.analysis.source
99+
);
103100
}
104101
}
105102

@@ -113,6 +110,10 @@ export function visit_component(node, context) {
113110
if (attribute.type === 'BindDirective' && attribute.name !== 'this') {
114111
context.state.analysis.uses_component_bindings = true;
115112
}
113+
114+
if (attribute.type === 'AttachTag') {
115+
disallow_unparenthesized_sequences(attribute.expression, context.state.analysis.source);
116+
}
116117
}
117118

118119
// If the component has a slot attribute — `<Foo slot="whatever" .../>` —
@@ -158,3 +159,18 @@ export function visit_component(node, context) {
158159
context.visit({ ...node.fragment, nodes: nodes[slot_name] }, state);
159160
}
160161
}
162+
163+
/**
164+
* @param {Expression} expression
165+
* @param {string} source
166+
*/
167+
function disallow_unparenthesized_sequences(expression, source) {
168+
if (expression.type === 'SequenceExpression') {
169+
let i = /** @type {number} */ (expression.start);
170+
while (--i > 0) {
171+
const char = source[i];
172+
if (char === '(') break; // parenthesized sequence expressions are ok
173+
if (char === '{') e.attribute_invalid_sequence_expression(expression);
174+
}
175+
}
176+
}

0 commit comments

Comments
 (0)