Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: attachments #15000

Open
wants to merge 19 commits into
base: main
Choose a base branch
from
Open

feat: attachments #15000

wants to merge 19 commits into from

Conversation

Rich-Harris
Copy link
Member

@Rich-Harris Rich-Harris commented Jan 13, 2025

What?

This PR introduces attachments, which are essentially a more flexible and modern version of actions.

Why?

Actions are neat but they have a number of awkward characteristics and limitations:

  • the syntax is very weird! <div use:foo={bar}> implies some sort of equality between foo and bar but actually means foo(div, bar). There's no way you could figure that out just by looking at it
  • the foo in use:foo has to be an identifier. You can't, for example, do use:createFoo() — it must have been declared elsewhere
  • as a corollary, you can't do 'inline actions'
  • it's not reactive. If foo changes, use:foo={bar} does not re-run. If bar changes, and foo returned an update method, that method will re-run, but otherwise (including if you use effects, which is how the docs recommend you use actions) nothing will happen
  • you can't use them on components
  • you can't spread them, so if you want to add both attributes and behaviours you have to jump through hoops

We can do much better.

How?

You can attach an attachment to an element with the {@attach fn} tag (which follows the existing convention used by things like {@html ...} and {@render ...}, where fn is a function that takes the element as its sole argument:

<div {@attach (node) => console.log(node)}>...</div>

This can of course be a named function, or a function returned from a named function...

<button {@attach tooltip('Hello')}>
  Hover me
</button>

...which I'd expect to be the conventional way to use attachments.

Attachments can be create programmatically and spread onto an object:

<script>
  const stuff = {
    class: 'cool-button',
    onclick: () => console.log('clicked'),
    [Symbol()]: (node) => alert(`I am a ${node.nodeName}`)
  };
</script>

<button {...stuff}>hello</button>

As such, they can be added to components:

<Button
  class="cool-button"
  onclick={() => console.log('clicked')}
  {@attach (node) => alert(`I am a ${node.nodeName}`)}
>
  hello
</Button>
<script>
  let { children, ...props } = $props();
</script>

<button {...props}>{@render children?.()}</button>

Since attachments run inside an effect, they are fully reactive.

I haven't figured out if it should be possible to return a cleanup function directly from an attachment, or if you should need to create a child effect

Because you can create attachments inline, you can do cool stuff like this, which is somewhat more cumbersome today.

When?

As soon as we bikeshed all the bikesheddable details.


While this is immediately useful as a better version of actions, I think the real fun will begin when we start considering this as a better version of transitions and animations as well. Today, the in:/out:/transition: directives are showing their age a bit. They're not very composable or flexible — you can't put them on components, they generally can't 'talk' to each other except in very limited ways, you can't transition multiple styles independently, you can't really use them for physics-based transitions, you can only use them on DOM elements rather than e.g. objects in a WebGL scene graph, and so on.

Ideally, instead of only having the declarative approach to transitions, we'd have a layered approach that made that flexibility possible. Two things in particular are needed: a way to add per-element lifecycle functions, and an API for delaying the destruction of an effect until some work is complete (which outro transitions uniquely have the power to do today). This PR adds the first; the second is a consideration for our future selves.

Before submitting the PR, please make sure you do the following

  • It's really useful if your PR references an issue where it is discussed ahead of time. In many cases, features are absent for a reason. For large changes, please create an RFC: https://github.com/sveltejs/rfcs
  • Prefix your PR title with feat:, fix:, chore:, or docs:.
  • This message body should clearly illustrate what problems it solves.
  • Ideally, include a test that fails without this PR but passes with it.
  • If this PR changes code within packages/svelte/src, add a changeset (npx changeset).

Tests and linting

  • Run the tests with pnpm test and lint the project with pnpm lint

Copy link

changeset-bot bot commented Jan 13, 2025

🦋 Changeset detected

Latest commit: 6402161

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
svelte Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@Rich-Harris
Copy link
Member Author

preview: https://svelte-dev-git-preview-svelte-15000-svelte.vercel.app/

this is an automated message

Copy link
Contributor

Playground

pnpm add https://pkg.pr.new/svelte@15000

@huntabyte
Copy link
Member

Would something like this work as well?

<script>
  	import { createAttachmentKey } from 'svelte/attachments';

  	const stuff = {
    	class: 'cool-button',
    	onclick: () => console.log('clicked'),
    	[createAttachmentKey()]: (node) => console.log(`I am one attachment`)
  	};

	const otherStuff = {
		[createAttachmentKey()]: (node) => console.log('I am another attachment')
	}
</script>

<button {...stuff} {...otherStuff}>hello</button>

Where the result on mount would be:

I am one attachment
I am another attachment

@JonathonRP
Copy link

Personally I would prefer a createAttachment like createSnippet. Just something to consider for the team

@Leonidaz
Copy link

nice! 👍

I wonder if it would be more flexible for composition if the syntax can work with named props.

programmatically:

<script>
  // reverse logic instead of symbol-ed key, a symbol-ed function wrapper
  import { createAttachment } from 'svelte/attachments';

  const stuff = {
    class: 'cool-button',
    onclick: () => console.log('clicked'),
    showAlert: createAttachment((node) => alert(`I am a ${node.nodeName}`)),
    logger: createAttachment((node) => console.log(`I am a ${node.nodeName}`)),
  };
</script>

<button {...stuff}>hello</button>

directly on components:

<Button
  class="cool-button"
  onclick={() => console.log('clicked')}
  showAlert={@attach (node) => alert(`I am a ${node.nodeName}`)}
  logger={@attach (node) => console.log(`I am a ${node.nodeName}`)}
>
  hello
</Button>

and spread in which case at runtime the prop values can be checked for a special attach symbol (the prop key names are irrelevant)

<script>
  let { children, ...props } = $props();
</script>

<button {...props}>{@render children?.()}</button>

or explicitly declare props, for further composition (and it would be nice for TypeScript declarations):

<script>
  import AnotherComponent from './AnotherComponent.svelte';
  let { children, showAlert, logger } = $props();
</script>

<button {@attach showAlert} {@attach logger}>{@render children?.()}</button>

<AnotherComponent logger={@attach logger} />

And with either syntax, one could also just pass in a prop as an "attachable" function without {@attach} syntax if they're going to eventually explicitly attach it to a DOM element without spreading.

<AnotherComponent {logger}  myAction={(node) => { /* do something */ } />
<!-- AnotherComponent.svelte -->
<script>
  let { logger, myAction } = $props();
</script>

<input {@attach logger} {@attach myAction}>

@mr-josh
Copy link

mr-josh commented Jan 14, 2025

Could svelte have a set of constant symbols (assuming we're using the Symbol API)? Could also allow for updating the transition directives.

Something like:

<script>
  import { ATTACHMENT_SYMBOL, TRANSITION_IN_SYMBOL } from "svelte/symbols";
  import { fade } from "svelte/transition";

  const stuff = {
    [ATTACHMENT_SYMBOL]: (node) => console.log("hello world"),
    [TRANSITION_IN_SYMBOL]: (node) => fade(node, { duration: 100 }),
  }; 
</script>

<button {...stuff}>hello</button>

@Conduitry
Copy link
Member

The purpose of having a function that returns symbols - rather than using a single symbol - is that it lets you have multiple attachments on a single element/component without them clobbering one another.

@JonathonRP
Copy link

The current rub with transitions is their css and/or tick methods that apply to style but if transitions were just attachments that modified the style attribute of node then they would just be attachments too...

@Thiagolino8
Copy link

The current rub with transitions is their css and/or tick methods that apply to style but if transitions were just attachments that modified the style attribute of node then they would just be attachments too...

Actions can already do this already, the advantage of transitions is to do this outside the main thread
In svelte 3/4 creating css transitions and in svelte 5 using the WAAPI

@Thiagolino8
Copy link

Thiagolino8 commented Jan 14, 2025

One of the advantages of the special syntax of actions was the fact that it generated shakable tree code

Attachments do not seem to have this advantage since every element needs to look for properties with the special symbol for special behavior

@Thiagolino8
Copy link

If I understand correctly, it is not possible to extract an attachment from the props and consequently it is also not possible to prevent an attachment from being passed to an element with spread props, using an attachment on a component is basically a redirect all

@JonathonRP
Copy link

The current rub with transitions is their css and/or tick methods that apply to style but if transitions were just attachments that modified the style attribute of node then they would just be attachments too...

Actions can already do this already, the advantage of transitions is to do this outside the main thread
In svelte 3/4 creating css transitions and in svelte 5 using the WAAPI

True, I'm curious about the waapi usage

@mr-josh
Copy link

mr-josh commented Jan 14, 2025

The purpose of having a function that returns symbols - rather than using a single symbol - is that it lets you have multiple attachments on a single element/component without them clobbering one another.

I'd be curious about the intention of this, cause intuitively I would assume using the function would override any previous definitions the same way standard merging of objects would. Allowing multiple of what at face value feels like the same key feels like it'll trip people up.
I think if you wanted to have multiple attachments through props, using a sequence function would be a lot clearer and guarantee what order attachments occur in.

<script>
  import { ATTACHMENT_SYMBOL } from "svelte/symbols";
  import { sequence } from "svelte/attachments";

  const attachmentA = (node) => console.log("first attachment");
  const attachmentA = (node) => console.log("second attachment");

  const stuff = {
    [ATTACHMENT_SYMBOL]: sequence(attachmentA, attachmentB),
  }; 
</script>

<button {...stuff}>hello</button>

@Rich-Harris
Copy link
Member Author

@huntabyte

Would something like this work as well?

try it and see :)

I wonder if it would be more flexible for composition if the syntax can work with named props.

You're just describing normal props! The {@attach ...} keyword is only useful when it's anonymous.

<MyComponent {@attach anonymousAttachment} named={namedAttachment} />
<script>
  let { named, ...props } = $props();
</script>

<div {@attach named} {...props} />

One of the advantages of the special syntax of actions was the fact that it generated shakable tree code

I don't follow? The only treeshaking that happens, happens in SSR mode — i.e. <div use:foo> doesn't result in foo being called on the server. That remains true for attachments. The additional runtime code required to support attachments is negligible.

If I understand correctly, it is not possible to extract an attachment from the props

It's deliberate that if you use {...stuff} that attachments will be included in that. If you really want to remove them it's perfectly possible, it's just an object with symbols. Create a derived that filters the symbols out if you need to, though I'm not sure why you'd want that.

Allowing multiple of what at face value feels like the same key feels like it'll trip people up.

Most of the time you're not interacting with the 'key', that's an advanced use case. You're just attaching stuff:

<div {attach foo()} {@attach bar()} {@attach etc()}>...</div>

One possibility for making that more concise is to allow a sequence...

<div {attach foo(), bar(), etc()}>...</div>

...but I don't know if that's a good idea.

@kran6a
Copy link

kran6a commented Jan 14, 2025

Love the proposal and how it simplified actions, specially the handler having a single parameter, which will not only encourage but force writing more composable attachments via HOFs.
i.e:

export const debounce = (cb: ()=>void)=>(ms: number)=>(element: HTMLElement)=>{
    // implementation intentionally left blank
}
<script lang="ts">
    const debounced_alert = debounce(()=>alert("You type too slow"));
</script>
<textarea {@attach debounced_alert(2000)}></textarea>

Personally I would prefer a block syntax rather than the PR one.

<!--Applies both attachments to input and textarea-->
{#attachment debounce(()=>alert("You type too slow"))(2000), debounce(()=>alert("Server is still waiting for input"))(3000)}
    <input type="text"/>
    <textarea></textarea>
{/attachment}

My reasons to prefer a block are:

  1. It is an already known syntax
  2. Easily discoverable via intellisense when you type {# (to write any other block) and the autocomplete brings up attachment as one of the options. I don't think anybody that does not know about attachments is going to discover the PR syntax via intellisense.
  3. Blocks are easier to read when the block content is a big tree since you can see the opening and closing. This is useful when the element that has the attachment is not an input/button but a clickoutside or a keydown on a whole page section.
  4. Syntax is cleaner even if you inline the attachment configuration options as otherwise they would be on the same line as 20 tailwind classes, an id, name, data- and aria- attributes.
  5. The {@something} syntax already exists and, until now, it could only be used inside an element/block, be it declaring a block-scoped constant with {@const}, rawdogging html with {@html}, or rendering with {@render}.
    Even debugging with {@debug} cannot be used in the opening tag like {@attachment}. This breaks sytax consistency and did not happen with the old use: syntax as the old something: syntax was always used on the opening tag of an element.

@Ocean-OS
Copy link
Contributor

I like this, my only concern is the similarity in syntax between this and logic tags. It may make new developers think that something like this is valid Svelte:

<div {@const ...}>

Or may make them try to do something like this:

<element>
{@attach ...}
</element>

@itsmikesharescode
Copy link

I love it!

@Theo-Steiner
Copy link
Contributor

Theo-Steiner commented Jan 14, 2025

Great iteration on actions, I love it!

Came here to ask how I could determine if an object property was an attachment, but looking at the source, it looks like isAttachmentKey is a helper to do just that!

Two things in particular are needed: a way to add per-element lifecycle functions, and an API for delaying the destruction of an effect until some work is complete (which outro transitions uniquely have the power to do today). This PR adds the first; the second is a consideration for our future selves.

I think it would be really intuitive if attachments could return an async cleanup function with the element/component being removed once the cleanup function (and all nested cleanup functions) settle/resolve!

@brunnerh
Copy link
Member

brunnerh commented Jan 21, 2025

I am a bit sceptical about just using any symbol for this. If something similar to attach is to be added in the future which requires a different signature or internal handling this could potentially require introducing a breaking change.

Presumably attachments will be added declaratively or spread as is in most cases, so I don't think it would be too much overhead to require importing a function that creates the symbol with a description that can be used to differentiate if necessary.

I.e. something like:

import { attachmentSymbol } from 'svelte';

const props = { [attachmentSymbol()]: node => { ... } };
export function attachmentSymbol() {
  return Symbol('svelte-attach');
}

It is also a lot more self-explanatory what is being added here.

@dummdidumm
Copy link
Member

Agree that using just the symbol is too broad. createAttachmentKey and isAttachmentKey leave the door open for using it for other things in the future, and also makes the intent much clearer of what's going on (something that we did in many places elsewhere in Svelte 5). { [createAttachmentKey()]: ... } gives me direct intellisense and clues as to what someone is doing there, compared to { [Symbol()]: ... } which means I a) gotta know that this is how you do attachment b) know that this object is meant to be spread onto a component/element, both which require non-trivial context knowledge.

@7nik
Copy link

7nik commented Jan 21, 2025

@Leonidaz the problem with the named attachments is that they clash with other props, and not all people use TS to prevent this:

<script>
  import { createAttachment } from 'svelte/attachments';

  const stuff = {
    class: createAttachment(...),
    onclick: createAttachment(...),
    disabled: createAttachment(...),
    someComponentProp: createAttachment(...),
    somePropThatWillAppearInTheFuture: createAttachment(...),
    showAlert: createAttachment(...),
    logger: createAttachment(...),
  };
</script>

<button {...stuff}>hello</button>

But you can use Object.values() to convert named attachments to an array:

<script>
  import { getStandardAttachments } from '/mylib';
  let props = $props();

  const { hover,  drag, ...restAttachments } = getStandardAttachments();
</script>

<div attach(hover, drag)>
    <button
        {...props}
        attach(...Object.values(restAttachments))
    ></button>
</div>

Though, I believe a better approach is to request attachments:

<script>
  import { getStandardAttachments } from '/mylib';
  let props = $props();

  const containerAttachments = getStandardAttachments("hover", "drag");
  const buttonAttachments = getStandardAttachments("focus", "tooltip", "aria");
  // or alternative way
  // const [requsted, rest] = getStandardAttachments(...list);
  const [containerAttachments, buttonAttachments] =  getStandardAttachments("hover", "drag");
</script>

<div attach(...containerAttachments)>
    <button
        {...props}
        attach(...buttonAttachments)
    ></button>
</div>

@Rich-Harris
Copy link
Member Author

The reason we have to accept any symbol is because components need to be able to declare whether or not they accept attachments, and if so what kind of element they will be applied to. That requires the use of an index signature, which can't differentiate between different symbols:

// allow any attachment
[key: symbol]: (node: T) => void | (() => void);

I suppose an alternative could be to use prefixed UUIDs — something like this:

<!-- this... -->
<Child foo={42} attach(blah) />
<!-- becomes this: -->
Child(node, {
  foo: 42,
  [`$$attach-${crypto.randomUUID()}`]: blah
});

The corresponding change to the types would be this:

   // allow any attachment 
-  [key: symbol]: (node: T) => void | (() => void); 
+  [key: `$$attach-${string}`]: (node: T) => void | (() => void); 

Technically this would be a breaking change since no prop names are reserved, and it would be weird for the names to show up as (enumerable, unless we jump through hoops) keys on the props object.

@brunnerh
Copy link
Member

I don't think the type signature needs to have that much of a bearing on this, many things are impossible to express in TS's types (e.g. rejecting negative numbers, constraining to integers, etc).

The types could still accept any symbol, it just would not have any effect if unknown symbols are used.

@Leonidaz
Copy link

the problem with the named attachments is that they clash with other props

@7nik I mentioned this in previous comments, just for posterity.

the names of the named att. props would not matter as what gets actually "added" to the element are just attachments themselves (recognized by svelte via a symbol on the callback (node) => {} function that createAttachment (I like attach) added. E.g. if elements in the future support a new attribute tooltip and the value of a tooltip prop passed in does not contain the attach symbol then it's just added as this new attribute tooltip, otherwise only the attach function is wired up, it never becomes a tooltip attribute.

Name "collisions", or actually overwriting, on components can happen just like with any other props.

Also, adding anonymous att's would still be available.

// adding a symbol to `attach((node) => {})`
const ATTACH_SYMBOL = Symbol('attach');

export function attach(fn) {
  if (!(ATTACH_SYMBOL in fn)) {
    fn[ATTACH_SYMBOL] = true;
  }		

  return fn;
}

@7nik
Copy link

7nik commented Jan 21, 2025

The named attributes make sense only when you decide what and where to attach. After it, the names make no sense - it is just bunches of attachments. And a component may provide props like containerAttachments, buttonAttachments, inputAttachments which are simply Attachment[].

@Leonidaz
Copy link

I explained it before, the names are better for semantics and readability on components. On elements (or for that matter on components) you can still for own clarity do this attach(namedAttachProp) with named att. props but you can also destructure named props without having to know what they are if they were used on components for semantics <div {...dontCareWhatTheseAre}>

@Rich-Harris
Copy link
Member Author

Please drop the 'named attachments' thing. It's not going to happen

@Not-Jayden
Copy link
Contributor

The reason we have to accept any symbol is because components need to be able to declare whether or not they accept attachments, and if so what kind of element they will be applied to. That requires the use of an index signature, which can't differentiate between different symbols:

Could the Svelte compiler manage a symbol registry with branded types? Something like:

type AttachmentKey = symbol & { __brand: 'AttachmentKey' };

const attachmentKeyRegistry = new Set<symbol>();

function createAttachmentKey(): AttachmentKey {
    const sym = Symbol(crypto.randomUUID()) as AttachmentKey;
    attachmentKeyRegistry.add(sym);
    return sym;
}

function isAttachmentKey(sym: symbol): sym is AttachmentKey {
    return attachmentKeyRegistry.has(sym);
}

const myAttachment = createAttachmentKey();

console.log(isAttachmentKey(myAttachment)); // true
console.log(isAttachmentKey(Symbol('OTHER'))); // false

Svelte would only make an attachment if the symbol is in the registry, and the branded type means component props can be typed as:

<script lang="ts">
    import type {AttachmentKey, AttachmentFunction} from "svelte/attachments";

    let { text, ...rest }: { text: string; [key: AttachmentKey]: AttachmentFunction; } = $props();
</script>

And of course it keeps the door open for other symbol based property types as discussed above.

@brunnerh
Copy link
Member

You don't need random IDs for symbols, they are already distinct. E.g.

function createAttachmentKey() {
	return Symbol('svelte-attach');
}
function isAttachmentKey(symbol) {
	return symbol.description == 'svelte-attach';
}

const a = createAttachmentKey();
const b = createAttachmentKey();
const c = Symbol('other');
console.log(a == b); // false
console.log(
	isAttachmentKey(a), // true
	isAttachmentKey(b), // true
	isAttachmentKey(c), // false
);

@Rich-Harris
Copy link
Member Author

Again: this is about types. It makes no sense to reserve space for future symbol usages (by using createAttachmentKey) when the necessary index type would make those future usages impossible anyway. Either we commit to using symbols for attachments (and only attachments) or we do the ugly (and technically breaking) prefix thing, or a secret third option that I'm not seeing.

Obviously we're in 'unknown unknown' territory here but if you're resistant to the idea of using generic symbols because of some hypothetical future need, it would be good to have some idea of what kind of future need that could possibly be. Keeping design space open is smart; settling on an inferior solution just in case isn't.

@dummdidumm
Copy link
Member

dummdidumm commented Jan 21, 2025

As @brunnerh says, we don't need to adhere too much to TypeScript here. We can start by using Symbols for the types regardless. We can also use [key: `$$attach-${string}`]: (node: T) => void | (() => void); while stating that this is only made up, it won't matter in practise:

  • you can only create them with createAttachmentKey(), which will become clear through failing once (if you ever try; why would you)
  • you will not see them show up in suggestions because it's an index signature
  • there's no actual possibility of this being a used property name, because people have the "$ is special, I shouldnt use that" rules in mind

settling on an inferior solution just in case isn't

FWIW I think "explicit API (createAttachmentKey/isAttachmentKey)" >>>" symbols implicitly meaning something" regardless of what we do with the types

@Rich-Harris
Copy link
Member Author

I suppose the secret third option could be to only ever allow a single attachment 'call' per element/component — instead of this...

<div
  class="modal"
  attach(trapfocus())
  attach(draggable({...}))
  attach(fly.in({...}))
  attach(fade.out({...}))
>
  <!-- ... -->
</div>

...this:

<div
  class="modal"
  attach(
    trapfocus(),
    draggable({...}),
    fly.in({...}),
    fade.out({...})
  )
>
  <!-- ... -->
</div>

@mr-josh
Copy link

mr-josh commented Jan 21, 2025

I'm a fan of the secret third method as it follows similarly to how kit does its sequence calls for other things.

Creating libraries and components feel a bit more natural in my head too if it were to flow with something like:

<div
  class="modal"
  attach(
    trapfocus(),
    ...attachmentProps,
  )
>
  <!-- ... -->
</div>

Though not sure if that's quite refined yet and my example is a little half baked.

@bcharbonnier
Copy link

I suppose the secret third option could be to only ever allow a single attachment 'call' per element/component — instead of this...

Which is aligned with HTML spec where attributes on element can only be specified one time, no?

@brunnerh
Copy link
Member

It's not an attribute, which is also why I prefer @attach(node => ...) over just attach(node => ...).

I don't think the just one call option is a good idea if that entails there being a single key. Symbols are nice because they do not conflict if new ones are created for every instance. With a single key it is easy to accidentally clobber attachments when merging props from different sources.

@qwuide
Copy link

qwuide commented Jan 21, 2025

I think about these attachments as positional entries that apply behaviour to the element in sequence, much like items in an array:

<div
  class="modal"
  [trapfocus(), draggable({...}), fly.in({...}), fade.out({...}), ...otherAttachments]
>
  <!-- ... -->
</div>

@bcharbonnier
Copy link

It's not an attribute, which is also why I prefer @Attach(node => ...) over just attach(node => ...).

That's why and what I'm fighting for. It should be (looks like) one attribute. And who care about forward compatibility, it takes months (even years) to standardize a new attribute in the specs, which let plenty of time to make non backward changes for any library out there.

Even for real thing like popover Chrome changed some names a couple times!!

Also, having an attribute on a DOM node in a Svelte template does not mean this attribute will end up on the final node, as Svelte compiler will recognize it and do some things with it.

I think about these attachments as positional entries that apply behaviour to the element in sequence, much like items in an array:

Which fits really well with a single attribute syntax.

<div attach={[trapfocus(), draggable({...})]} >

It sounds easier to me from all perspective: Developer Experience, highlighter tools, ...

@webJose
Copy link
Contributor

webJose commented Jan 21, 2025

I think @bcharbonnier has a good point: Even if today someone presented the proposal for the "attach" HTML attribute, it would easily take one year for it to be approved, plus some extra time until the attribute is available in the wild. That should be plenty of time to make a new major Svelte version.

But let's say we take prevision further: @attach instead of attach.

<div @attach={[trapfocus(), draggable({...})]} >

I don't think the standards will ever define an attribute that starts with "@".

BTW, I support this for the typing perspective. I'm ok with this or the alternate syntax. Syntax for me is not an issue. {@attach fn} or attach(fn) are fine.

@Not-Jayden
Copy link
Contributor

Not-Jayden commented Jan 21, 2025

Again: this is about types. It makes no sense to reserve space for future symbol usages (by using createAttachmentKey) when the necessary index type would make those future usages impossible anyway.

Ahh I completely forgot about the index signature type limitations, that's very unfortunate 😕

I think there's probably still a workable solution for a single unique Attachments symbol (Symbol.for('svelte.attachments')) that accepts an array of attachments.

For component attachments, it would look something like:

<script lang="ts">
  import { Attachments, type AttachmentFunction, attach } from 'svelte/attachments';

  let { 
    // Can support extracting just the attachments to a single variable via the `Attachments` key if desired
    // [Attachments]: attachments = [],
    ...props,
  }: { 
    [Attachments]?: AttachmentFunction[] 
  } = $props();
</script>;

<input type="text" {...props}  />
<!-- or for the destructured case -->
<input type="text" attach(...attachments) />

I think the biggest drawback of doing this is it becomes very easy to unintentionally override all the attachments when spreading two objects together. To prevent this I'd probably suggest some special handling for how attachments are merged compared to normal properties on components/elements, and maybe even provide a mergeProps() utility like Vue and Solid.

Something like:

<script lang="ts">
  import { Attachments, type AttachmentFunction } from 'svelte/attachments';
  import { autoSelect, tooltip } from './attachments.js';
  import { mergeProps } from 'svelte';

  const baseProps = {
    [Attachments]: [autoSelect],
  };

  const otherProps = {
    [Attachments]: [tooltip('Enter your name')], 
  };

  // ⚠️ Potential footgun case - `Attachments` is overwritten by otherProps (normal JS behaviour)
  const spreadProps = { ...baseProps, ...otherProps }; 

 // Manually merging attachments by spreading inside $$Attachments
  const manualMergeProps = {
    ...baseProps,
    ...otherProps,
    [Attachments]: [...baseProps[Attachments], ...otherProps[Attachments]]
  };

  // Maybe - Introduce mergeProps convenience utility that combines attachments (and potentially merge other things like events/classes??)
  const mergedProps = mergeProps(baseProps, otherProps);
</script>

<!-- Spreading baseProps and otherProps separately, both arrays attachments should be applied -->
<input type="text" {...baseProps} {...otherProps} />

<!-- ⚠️ Attachments will be overwritten, only tooltip from otherProps will remain -->
<input type="text" {...{ ...baseProps, ...otherProps }} />

<!-- Calling inline attach() will also merge with the other attachments -->
<input type="text" {...mergedProps} attach(tooltip("Now there's two of us!")) />

@webJose
Copy link
Contributor

webJose commented Jan 21, 2025

Crazy thought:

@@[reserved name] === @[reserved name] from the get-go, and people are instructed to use @@ if they want to be really future-proof. If [reserved name] becomes an HTML attribute, doesn't matter because of "@". If @[reserved name] becomes an HTML attribute, doesn't matter because of the "@@" variant.

Too much? Not all ideas need to be golden. 😄 Spare my life if this is madness.

This would only apply for the attribuite-like syntax, which I like because it is typed normally.

@Leonidaz
Copy link

Leonidaz commented Jan 22, 2025

just fyi, it's just a usage example, don't want to open up another discussion on this pr. I'm @lt3 on discord if you want to dm me.

@huntabyte and others who thought it might be useful to have named props and destructure without caring what's what,

With the new attach() attribute syntax and support for arrays #15076, you can implement named att. params on your own by attaching a symbol to callback functions. I created an example, see below.

All callbacks whether just functions, or functions as parts of arrays or objects can be marked for attachment via attachify and in the target component just need to get them all via filterAttachments. The filterAttachments() also gets you all actual attachments that were added via the proposed attach() attribute.

NOTE: $state() is used to make arrays and objects reactive on mutations as per usual

named attach props via userland

@Pronian
Copy link

Pronian commented Jan 22, 2025

What if instead of checking the prop key to determine if it's an attachment, we check the value instead? We could check if the value is a function and has a specific unique property on that function. The check could look some think like this:

typeof value === 'function' && value.$$svelte_directive_type === 'attachment'

Pros:

  1. Would work well with TypeScript
  2. Would allow us to have spreadable attachments with regular string keys
  3. Would allow us to have "unnamed" attachments with a Symbol as a key
  4. Would allow us to use the same strategy for transition:, in:, out: directives while also being able to differentiate them on a TypeScript and runtime level
  5. We could limit the runtime check only to spread properties. Attachments using the @attach directive would be known at compile time.

Cons:

  1. Would require re-checking spread properties every time their value changes
  2. Spread properties could awkwardly switch between attachments and regular properties, potentially causing confusion (could be partially solved by preventing properties that were attachments from being considered as regular properties after updating)
  3. We would need a helper function for creating attachments when not using the @attach directive
  4. Could possibly limit optimization opportunities???

I kinda think the pros outweigh the cons, what do you guys think?

@Rich-Harris
Copy link
Member Author

what do you guys think?

I think this discussion thread has gone on for too long, because we're starting to have the same conversations on repeat :)

@Pronian
Copy link

Pronian commented Jan 22, 2025

I apologize, I didn't see this suggestion. I must have missed it 😞

@Ocean-OS
Copy link
Contributor

I think this discussion thread has gone on for too long, because we're starting to have the same conversations on repeat :)

I agree, it's become the most commented PR on this repo in less than two weeks...

@JonathonRP
Copy link

Yeah, I was hoping to discuss more about the attachment and transitions/animations apis but it seems others cared more about syntax, and on that I'm fine with any and leave it to svelte team and trust in y'all

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.