diff --git a/packages/svelte/.gitignore b/packages/svelte/.gitignore index d0c4ba8d6144..622014804bd4 100644 --- a/packages/svelte/.gitignore +++ b/packages/svelte/.gitignore @@ -3,6 +3,7 @@ /compiler/index.js /action.d.ts +/attachments.d.ts /animate.d.ts /compiler.d.ts /easing.d.ts diff --git a/packages/svelte/elements.d.ts b/packages/svelte/elements.d.ts index 7b67e2a07ebd..eda33c434c64 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 { Attachments } 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'; @@ -861,7 +863,7 @@ export interface HTMLAttributes extends AriaAttributes, D [key: `data-${string}`]: any; // allow any attachment - [key: symbol]: (node: T) => void | (() => void); + [Attachments]: Array<(node: T) => void | (() => void)>; } export type HTMLAttributeAnchorTarget = '_self' | '_blank' | '_parent' | '_top' | (string & {}); diff --git a/packages/svelte/package.json b/packages/svelte/package.json index f426b97be4aa..ece8b73df2b8 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -29,6 +29,10 @@ "./action": { "types": "./types/index.d.ts" }, + "./attachments": { + "types": "./types/index.d.ts", + "default": "./src/attachments/index.js" + }, "./animate": { "types": "./types/index.d.ts", "default": "./src/animate/index.js" diff --git a/packages/svelte/scripts/generate-types.js b/packages/svelte/scripts/generate-types.js index d44afe8205a8..4bd2763a307d 100644 --- a/packages/svelte/scripts/generate-types.js +++ b/packages/svelte/scripts/generate-types.js @@ -8,7 +8,16 @@ const pkg = JSON.parse(fs.readFileSync(`${dir}/package.json`, 'utf-8')); // For people not using moduleResolution: 'bundler', we need to generate these files. Think about removing this in Svelte 6 or 7 // It may look weird, but the imports MUST be ending with index.js to be properly resolved in all TS modes -for (const name of ['action', 'animate', 'easing', 'motion', 'store', 'transition', 'legacy']) { +for (const name of [ + 'action', + 'attachments', + 'animate', + 'easing', + 'motion', + 'store', + 'transition', + 'legacy' +]) { fs.writeFileSync(`${dir}/${name}.d.ts`, "import './types/index.js';\n"); } @@ -29,6 +38,7 @@ await createBundle({ modules: { [pkg.name]: `${dir}/src/index.d.ts`, [`${pkg.name}/action`]: `${dir}/src/action/public.d.ts`, + [`${pkg.name}/attachments`]: `${dir}/src/attachments/public.d.ts`, [`${pkg.name}/animate`]: `${dir}/src/animate/public.d.ts`, [`${pkg.name}/compiler`]: `${dir}/src/compiler/public.d.ts`, [`${pkg.name}/easing`]: `${dir}/src/easing/index.js`, diff --git a/packages/svelte/src/attachments/index.js b/packages/svelte/src/attachments/index.js new file mode 100644 index 000000000000..87d0fa3ac002 --- /dev/null +++ b/packages/svelte/src/attachments/index.js @@ -0,0 +1 @@ +export const Attachments = Symbol.for('svelte.attachments'); diff --git a/packages/svelte/src/attachments/public.d.ts b/packages/svelte/src/attachments/public.d.ts new file mode 100644 index 000000000000..b1e6c5f2c1db --- /dev/null +++ b/packages/svelte/src/attachments/public.d.ts @@ -0,0 +1,4 @@ +/** + * A unique symbol used for defining the attachments to be applied to an element or component. + */ +export const Attachments: unique symbol; 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 47b7b4436878..99b931abbd40 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 @@ -1,4 +1,4 @@ -/** @import { BlockStatement, Expression, ExpressionStatement, Identifier, MemberExpression, Pattern, Property, SequenceExpression, Statement } from 'estree' */ +/** @import { BlockStatement, Expression, ExpressionStatement, Identifier, MemberExpression, Pattern, Property, SequenceExpression, Statement, SpreadElement } from 'estree' */ /** @import { AST } from '#compiler' */ /** @import { ComponentContext } from '../../types.js' */ import { dev, is_ignored } from '../../../../../state.js'; @@ -10,6 +10,39 @@ import { build_attribute_value } from '../shared/element.js'; import { build_event_handler } from './events.js'; import { determine_slot } from '../../../../../utils/slot.js'; +/** + * @param {Property} prop + * @param {ComponentContext} context + * @returns {boolean} + */ +function is_attachments_prop(prop, context) { + if (prop.key.type !== 'Identifier') { + return false; + } + + const binding = context.state.scope?.get?.(prop.key.name) ?? undefined; + const expression = prop.computed && prop.key.type === 'Identifier' ? prop.key : binding?.initial; + + if (!expression || expression.type !== 'CallExpression') { + return false; + } + + if ( + expression.callee.type !== 'MemberExpression' || + expression.callee.object.type !== 'Identifier' || + expression.callee.object.name !== 'Symbol' || + expression.callee.property.type !== 'Identifier' || + expression.callee.property.name !== 'for' || + expression.arguments.length !== 1 || + expression.arguments[0].type !== 'Literal' || + expression.arguments[0].value !== 'svelte.attachments' + ) { + return false; + } + + return true; +} + /** * @param {AST.Component | AST.SvelteComponent | AST.SvelteSelf} node * @param {string} component_name @@ -50,6 +83,9 @@ export function build_component(node, component_name, context, anchor = context. /** @type {ExpressionStatement[]} */ const binding_initializers = []; + /** @type {Array} */ + const all_attachments = []; + /** * If this component has a slot property, it is a named slot within another component. In this case * the slot scope applies to the component itself, too, and not just its children. @@ -129,6 +165,14 @@ export function build_component(node, component_name, context, anchor = context. } else { props_and_spreads.push(expression); } + + // Handle attachments from spread attributes + const symbol = b.call('Symbol.for', b.literal('svelte.attachments')); + const member = b.member(expression, symbol, true); // computed property + const default_empty = b.array([]); + const attachments_expr = b.logical('??', member, default_empty); + + all_attachments.push(b.spread(attachments_expr)); } else if (attribute.type === 'Attribute') { if (attribute.name.startsWith('--')) { custom_css_props.push( @@ -262,24 +306,36 @@ export function build_component(node, component_name, context, anchor = context. } } } else if (attribute.type === 'Attachment') { - // TODO do we need to create a derived here? for (const attachment of attribute.attachments) { - push_prop( - b.prop( - 'get', - b.call('Symbol'), - /** @type {Expression} */ ( - context.visit(attachment.type === 'SpreadElement' ? attachment.argument : attachment) - ), - true - ) - ); + if (attachment.type === 'SpreadElement') { + const visited = /** @type {ExpressionStatement} */ (context.visit(attachment.argument)); + all_attachments.push(b.spread(visited.expression)); + } else { + const visited = /** @type {ExpressionStatement} */ (context.visit(attachment)); + all_attachments.push(visited.expression); + } } } } delayed_props.forEach((fn) => fn()); + if (all_attachments.length > 0) { + const attachment_symbol = b.member( + b.id('Symbol'), + b.call('for', b.literal('svelte.attachments')) + ); + + push_prop( + b.prop( + 'init', + attachment_symbol, + b.array(all_attachments), + true // Mark as computed property + ) + ); + } + if (slot_scope_applies_to_itself) { context.state.init.push(...lets); } diff --git a/packages/svelte/src/compiler/utils/builders.js b/packages/svelte/src/compiler/utils/builders.js index ecb595d74dbd..90ee5ea09d33 100644 --- a/packages/svelte/src/compiler/utils/builders.js +++ b/packages/svelte/src/compiler/utils/builders.js @@ -229,12 +229,21 @@ export function function_declaration(id, params, body) { } /** - * @param {string} name + * @param {string | ESTree.Expression} name_or_expr * @param {ESTree.Statement[]} body - * @returns {ESTree.Property & { value: ESTree.FunctionExpression}}} - */ -export function get(name, body) { - return prop('get', key(name), function_builder(null, [], block(body))); + * @returns {ESTree.Property & { value: ESTree.FunctionExpression }} + */ +export function get(name_or_expr, body) { + let key_expr; + let computed = false; + if (typeof name_or_expr === 'string') { + key_expr = key(name_or_expr); + computed = key_expr.type !== 'Identifier'; + } else { + key_expr = name_or_expr; + computed = true; + } + return prop('get', key_expr, function_builder(null, [], block(body)), computed); } /** @@ -380,12 +389,21 @@ export function sequence(expressions) { } /** - * @param {string} name + * @param {string | ESTree.Expression} name_or_expr * @param {ESTree.Statement[]} body - * @returns {ESTree.Property & { value: ESTree.FunctionExpression}} - */ -export function set(name, body) { - return prop('set', key(name), function_builder(null, [id('$$value')], block(body))); + * @returns {ESTree.Property & { value: ESTree.FunctionExpression }} + */ +export function set(name_or_expr, body) { + let key_expr; + let computed = false; + if (typeof name_or_expr === 'string') { + key_expr = key(name_or_expr); + computed = key_expr.type !== 'Identifier'; + } else { + key_expr = name_or_expr; + computed = true; + } + return prop('set', key_expr, function_builder(null, [id('$$value')], block(body)), computed); } /** diff --git a/packages/svelte/src/internal/client/constants.js b/packages/svelte/src/internal/client/constants.js index a4840ce4ebd0..42d7db4f174f 100644 --- a/packages/svelte/src/internal/client/constants.js +++ b/packages/svelte/src/internal/client/constants.js @@ -25,3 +25,4 @@ export const STATE_SYMBOL = Symbol('$state'); export const STATE_SYMBOL_METADATA = Symbol('$state metadata'); export const LEGACY_PROPS = Symbol('legacy props'); export const LOADING_ATTR_SYMBOL = Symbol(''); +export const ATTACHMENTS_SYMBOL = Symbol.for('svelte.attachments'); diff --git a/packages/svelte/src/internal/client/dom/elements/attributes.js b/packages/svelte/src/internal/client/dom/elements/attributes.js index 17362cedea1d..4d4539ba5778 100644 --- a/packages/svelte/src/internal/client/dom/elements/attributes.js +++ b/packages/svelte/src/internal/client/dom/elements/attributes.js @@ -4,7 +4,7 @@ import { get_descriptors, get_prototype_of } from '../../../shared/utils.js'; import { create_event, delegate } from './events.js'; import { add_form_reset_listener, autofocus } from './misc.js'; import * as w from '../../warnings.js'; -import { LOADING_ATTR_SYMBOL } from '../../constants.js'; +import { ATTACHMENTS_SYMBOL, LOADING_ATTR_SYMBOL } from '../../constants.js'; import { queue_idle_task } from '../task.js'; import { is_capture_event, is_delegated, normalize_attribute } from '../../../../utils.js'; import { @@ -416,8 +416,11 @@ export function set_attributes( } } - for (let symbol of Object.getOwnPropertySymbols(next)) { - attach(element, () => next[symbol]); + const attachments = next[ATTACHMENTS_SYMBOL]; + if (attachments) { + for (let attachment of attachments) { + attach(element, () => attachment); + } } return current; diff --git a/packages/svelte/tsconfig.json b/packages/svelte/tsconfig.json index c9f0fb3b2bba..7829c3c2e7a5 100644 --- a/packages/svelte/tsconfig.json +++ b/packages/svelte/tsconfig.json @@ -18,6 +18,7 @@ "acorn-typescript": ["./src/compiler/phases/1-parse/ambient.d.ts"], "svelte": ["./src/index.d.ts"], "svelte/action": ["./src/action/public.d.ts"], + "svelte/attachments": ["./src/attachments/public.d.ts"], "svelte/compiler": ["./src/compiler/public.d.ts"], "svelte/events": ["./src/events/public.d.ts"], "svelte/internal/client": ["./src/internal/client/index.js"], diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index ad328b11acf4..055d08cf4ebd 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -593,6 +593,15 @@ declare module 'svelte/action' { export {}; } +declare module 'svelte/attachments' { + /** + * A unique symbol used for defining the attachments to be applied to an element or component. + */ + export const Attachments: unique symbol; + + export {}; +} + declare module 'svelte/animate' { // todo: same as Transition, should it be shared? export interface AnimationConfig { @@ -1881,10 +1890,10 @@ declare module 'svelte/motion' { * const tween = Tween.of(() => number); * * ``` - * + * */ static of(fn: () => U, options?: TweenedOptions | undefined): Tween; - + constructor(value: T, options?: TweenedOptions); /** * Sets `tween.target` to `value` and returns a `Promise` that resolves if and when `tween.current` catches up to it. @@ -1903,21 +1912,21 @@ declare module 'svelte/motion' { declare module 'svelte/reactivity' { export class SvelteDate extends Date { - + constructor(...params: any[]); #private; } export class SvelteSet extends Set { - + constructor(value?: Iterable | null | undefined); - + add(value: T): this; #private; } export class SvelteMap extends Map { - + constructor(value?: Iterable | null | undefined); - + set(key: K, value: V): this; #private; } @@ -1927,7 +1936,7 @@ declare module 'svelte/reactivity' { } const REPLACE: unique symbol; export class SvelteURLSearchParams extends URLSearchParams { - + [REPLACE](params: URLSearchParams): void; #private; } @@ -1999,7 +2008,7 @@ declare module 'svelte/reactivity' { */ export function createSubscriber(start: (update: () => void) => (() => void) | void): () => void; class ReactiveValue { - + constructor(fn: () => T, onsubscribe: (update: () => void) => void); get current(): T; #private; @@ -2064,7 +2073,7 @@ declare module 'svelte/reactivity/window' { get current(): number | undefined; }; class ReactiveValue { - + constructor(fn: () => T, onsubscribe: (update: () => void) => void); get current(): T; #private;