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/packages/svelte/elements.d.ts b/packages/svelte/elements.d.ts index 604403f0a261..7b67e2a07ebd 100644 --- a/packages/svelte/elements.d.ts +++ b/packages/svelte/elements.d.ts @@ -859,6 +859,9 @@ export interface HTMLAttributes extends AriaAttributes, D // allow any data- attribute [key: `data-${string}`]: any; + + // allow any attachment + [key: symbol]: (node: T) => void | (() => void); } export type HTMLAttributeAnchorTarget = '_self' | '_blank' | '_parent' | '_top' | (string & {}); 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 29e8a0e54143..8f01af3b7507 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..13d83e0e09f9 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,24 @@ 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 + }; + + return attachment; + } + if (parser.eat('...')) { const expression = read_expression(parser); 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 582c32b534ec..210381e2fdd2 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 ffd06dfd866f..eef2aa3aa5ef 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 @@ -82,7 +82,7 @@ export function RegularElement(node, context) { /** @type {AST.StyleDirective[]} */ const style_directives = []; - /** @type {Array} */ + /** @type {Array} */ const other_directives = []; /** @type {ExpressionStatement[]} */ @@ -152,6 +152,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 f509cb41a7d8..2882da7d2f52 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 @@ -261,6 +261,16 @@ export function build_component(node, component_name, context, anchor = context. ); } } + } else if (attribute.type === 'AttachTag') { + // TODO do we need to create a derived here? + push_prop( + b.prop( + 'get', + b.call('Symbol'), + /** @type {Expression} */ (context.visit(attribute.expression)), + true + ) + ); } } diff --git a/packages/svelte/src/compiler/types/template.d.ts b/packages/svelte/src/compiler/types/template.d.ts index 8be9aed17723..cafea6894cac 100644 --- a/packages/svelte/src/compiler/types/template.d.ts +++ b/packages/svelte/src/compiler/types/template.d.ts @@ -174,6 +174,12 @@ export namespace AST { }; } + /** A `{@attach foo(...)} tag */ + export interface AttachTag extends BaseNode { + type: 'AttachTag'; + expression: Expression; + } + /** An `animate:` directive */ export interface AnimateDirective extends BaseNode { type: 'AnimateDirective'; @@ -273,7 +279,7 @@ export namespace AST { interface BaseElement extends BaseNode { name: string; - attributes: Array; + attributes: Array; fragment: Fragment; } @@ -549,6 +555,7 @@ export namespace AST { | AST.Attribute | AST.SpreadAttribute | Directive + | AST.AttachTag | AST.Comment | Block; 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 a2fffe869660..17362cedea1d 100644 --- a/packages/svelte/src/internal/client/dom/elements/attributes.js +++ b/packages/svelte/src/internal/client/dom/elements/attributes.js @@ -13,6 +13,7 @@ import { set_active_effect, set_active_reaction } from '../../runtime.js'; +import { attach } from './attachments.js'; import { clsx } from '../../../shared/attributes.js'; /** @@ -245,8 +246,8 @@ export function set_custom_element_data(node, prop, value) { /** * Spreads attributes onto a DOM element, taking into account the currently set attributes * @param {Element & ElementCSSInlineStyle} element - * @param {Record | undefined} prev - * @param {Record} next New attributes - this function mutates this object + * @param {Record | undefined} prev + * @param {Record} next New attributes - this function mutates this object * @param {string} [css_hash] * @param {boolean} [preserve_attribute_case] * @param {boolean} [is_custom_element] @@ -415,6 +416,10 @@ export function set_attributes( } } + for (let symbol of Object.getOwnPropertySymbols(next)) { + 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 2bf58c51f75d..d0ad116b67bf 100644 --- a/packages/svelte/src/internal/client/index.js +++ b/packages/svelte/src/internal/client/index.js @@ -27,6 +27,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 3e5a0258c744..df384c911c4a 100644 --- a/packages/svelte/src/internal/client/reactivity/props.js +++ b/packages/svelte/src/internal/client/reactivity/props.js @@ -231,9 +231,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 @@ +
{}} {@attach (node) => {}}>
diff --git a/packages/svelte/tests/parser-modern/samples/attachments/output.json b/packages/svelte/tests/parser-modern/samples/attachments/output.json new file mode 100644 index 000000000000..42e9880fccdd --- /dev/null +++ b/packages/svelte/tests/parser-modern/samples/attachments/output.json @@ -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 +} diff --git a/packages/svelte/tests/runtime-runes/samples/attachment-basic/_config.js b/packages/svelte/tests/runtime-runes/samples/attachment-basic/_config.js new file mode 100644 index 000000000000..1be47370691a --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/attachment-basic/_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-basic/main.svelte b/packages/svelte/tests/runtime-runes/samples/attachment-basic/main.svelte new file mode 100644 index 000000000000..1a1f74e4a94a --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/attachment-basic/main.svelte @@ -0,0 +1 @@ +
node.textContent = node.nodeName}>
diff --git a/packages/svelte/tests/runtime-runes/samples/attachment-component-spread/Child.svelte b/packages/svelte/tests/runtime-runes/samples/attachment-component-spread/Child.svelte new file mode 100644 index 000000000000..6760da61faeb --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/attachment-component-spread/Child.svelte @@ -0,0 +1,5 @@ + + +
diff --git a/packages/svelte/tests/runtime-runes/samples/attachment-component-spread/_config.js b/packages/svelte/tests/runtime-runes/samples/attachment-component-spread/_config.js new file mode 100644 index 000000000000..fa1c9b059904 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/attachment-component-spread/_config.js @@ -0,0 +1,6 @@ +import { test } from '../../test'; + +export default test({ + ssrHtml: `
`, + html: `
set from component
` +}); diff --git a/packages/svelte/tests/runtime-runes/samples/attachment-component-spread/main.svelte b/packages/svelte/tests/runtime-runes/samples/attachment-component-spread/main.svelte new file mode 100644 index 000000000000..cf2551a8cfa8 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/attachment-component-spread/main.svelte @@ -0,0 +1,9 @@ + + + diff --git a/packages/svelte/tests/runtime-runes/samples/attachment-component/Child.svelte b/packages/svelte/tests/runtime-runes/samples/attachment-component/Child.svelte new file mode 100644 index 000000000000..6760da61faeb --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/attachment-component/Child.svelte @@ -0,0 +1,5 @@ + + +
diff --git a/packages/svelte/tests/runtime-runes/samples/attachment-component/_config.js b/packages/svelte/tests/runtime-runes/samples/attachment-component/_config.js new file mode 100644 index 000000000000..fa1c9b059904 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/attachment-component/_config.js @@ -0,0 +1,6 @@ +import { test } from '../../test'; + +export default test({ + ssrHtml: `
`, + html: `
set from component
` +}); diff --git a/packages/svelte/tests/runtime-runes/samples/attachment-component/main.svelte b/packages/svelte/tests/runtime-runes/samples/attachment-component/main.svelte new file mode 100644 index 000000000000..3468ee50a2cd --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/attachment-component/main.svelte @@ -0,0 +1,5 @@ + + + node.textContent = 'set from component'} /> diff --git a/packages/svelte/tests/runtime-runes/samples/attachment-reactive/_config.js b/packages/svelte/tests/runtime-runes/samples/attachment-reactive/_config.js new file mode 100644 index 000000000000..7d0502590b5d --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/attachment-reactive/_config.js @@ -0,0 +1,14 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + ssrHtml: `
`, + html: `
1
`, + + test: ({ assert, target }) => { + const btn = target.querySelector('button'); + + flushSync(() => btn?.click()); + assert.htmlEqual(target.innerHTML, `
2
`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/attachment-reactive/main.svelte b/packages/svelte/tests/runtime-runes/samples/attachment-reactive/main.svelte new file mode 100644 index 000000000000..9fa3cfdb6798 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/attachment-reactive/main.svelte @@ -0,0 +1,6 @@ + + +
node.textContent = value}>
+ 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..804c9cfede47 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/attachment-spread/main.svelte @@ -0,0 +1,7 @@ + + +
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 d00b2b01ed18..7b3a77ae6fce 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -1050,6 +1050,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'; @@ -1132,7 +1138,7 @@ declare module 'svelte/compiler' { interface BaseElement extends BaseNode { name: string; - attributes: Array; + attributes: Array; fragment: Fragment; } @@ -1322,6 +1328,7 @@ declare module 'svelte/compiler' { | AST.Attribute | AST.SpreadAttribute | Directive + | AST.AttachTag | AST.Comment | Block;