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!

@PuruVJ
Copy link
Collaborator

PuruVJ commented Jan 15, 2025

Similar to how 'on' is conventionally used for events like onclick

on is not a prefix, its just exposing the actual HTML event name as it is! Whereas attach is an arbitrary prefix out of nowhere which would break Types, require a major, as well as harder to explain and too hard to spot in the wild.

@mrwokkel
Copy link

It would not brake types any harder than custom events? Also 'on' IS a prefix although not created by svelte.

@Rich-Harris
Copy link
Member Author

Rich-Harris commented Jan 15, 2025

@huntabyte As a point of order, your 'named' code wouldn't actually work — you need to jump through some extra hoops:

+import { createAttachment } from 'svelte/attachments';

// named
-const namedBaseAttachments = { hover: node => {}, focus: node => {} }
-const namedDragAttachments = { drag: node => {}, dropzone: node => {} }
-const namedTooltipAttachments = { tooltip: node => {}, aria: node => {} }
+const namedBaseAttachments = { hover: createAttachment(node => {}), focus: createAttachment(node => {}) }
+const namedDragAttachments = { drag: createAttachment(node => {}), dropzone: createAttachment(node => {}) }
+const namedTooltipAttachments = { tooltip: createAttachment(node => {}), aria: createAttachment(node => {}) }

But leaving that aside, let's interrogate this in more detail: there are basically two possibilities, one in which Button 'knows' about these specific attachments, and one in which it doesn't. The latter case is easy, because it has no reason to do anything other than spread everything:

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

In this case there's no advantage to the consumer to using named attachments — the name is an opportunity for accidental clobbering, nothing more.

In the other case, Button is expecting specific attachments for hover and drag and tooltip behaviour and so on. In that case you'd have to do this...

<button {@attach hover} {@attach drag} {@attach tooltip} {...props}>{@render children?.()}</button>

...which seems onerous. But look closer: it makes no sense to do this. If my Button component is exposing tooltip functionality, why is it doing so via a generic could-be-anything-including-transitions-one-day attachment? The answer is: it wouldn't. It would do something like this...

<script>
  import { on } from 'svelte/events';

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

<button
  {...props}
  {@attach hover && (node) => {
    on(node, 'mouseenter', () => {
      const cleanup = hover();
      on(node, 'mouseleave', cleanup);
    });
  }}
  {@attach tooltip && (node) => {
    const { destroy } = tippy(node, { content: tooltip });
    return destroy;  
  }}
>

...and you'd use it like this:

<Button
  class="cool-button"
  onclick={() => console.log('clicked')}
  tooltip="wheee!!!!"
  {@attach fade.in()}
>

To reiterate the point about clobbering: a function that creates a bunch of attachments (or attachments and props) for use on arbitrary elements can't just claim random names for itself. Even if it studiously avoids any attribute that exists in HTML today, it can't guarantee attributes won't be added in future, and it can't guarantee those attributes/properties aren't in use by custom elements.

What am I missing here?

@paoloricciuti
Copy link
Member

What am I missing here?

I think the main point of @huntabyte is not for users of components but users of functions. Libraries like melt provide actions to use on elements, not components. And his point is that as a library maintainer and user is much better to have named attachments. But at the same time you might want to exclude some attachment because you don't need them. So if you have something like this

<script>
    let { drag, ...other_20_attachments } = get_attachments();
</script>

<div {...other_20_attachments} />

You currently have no way of doing it. I think this could be solved by this tho

<script>
    let { drag, ...other_20_attachments } = get_attachments();
</script>

<div {@attach ...other_20_attachments} />

@dummdidumm
Copy link
Member

dummdidumm commented Jan 15, 2025

This problem is a similar to bindings which you also can't spread today, and some people requested bind:{...allBindings} for this (or requested to "just make {...props} apply the bindings")

@Leonidaz
Copy link

To reiterate the point about clobbering: a function that creates a bunch of attachments (or attachments and props) for use on arbitrary elements can't just claim random names for itself. Even if it studiously avoids any attribute that exists in HTML today, it can't guarantee attributes won't be added in future, and it can't guarantee those attributes/properties aren't in use by custom elements.

I'm not sure I understand this point as it pertains to attachments. If the value of a named attachment property is an attachment (identify by Symbol), then the prop / attribute name is completely ignored and never added to the dom elements. As far as components clubbering is possible just like with any prop. Or is this about something else?

@JonathonRP
Copy link

I had an idea to possible help with having names for code base reuse of common attachment functions

-import { createAttachment } from 'svelte/attachments';

// named
-const namedBaseAttachments = { hover: node => {}, focus: node => {} }
-const namedDragAttachments = { drag: node => {}, dropzone: node => {} }
-const namedTooltipAttachments = { tooltip: node => {}, aria: node => {} }
-const namedBaseAttachments = { hover: createAttachment(node => {},) focus: createAttachment(node => {}) }
-const namedDragAttachments = { drag: createAttachment(node => {},) dropzone: createAttachment(node => {}) }
-const namedTooltipAttachments = { tooltip: createAttachment(node => {},) aria: createAttachment(node => {}) }
+const namedBaseAttachments = new Map([['hover', node => {}], ['focus', node => {}]])
+const namedDragAttachments = new Map([['drag', node => {}], ['dropzone': node => {}]])
+const namedTooltipAttachments = new Map([['tooltip', node => {}], ['aria', node => {}]])

@huntabyte, I know this is a little harder to read, maybe not so clear as Object but it would allow for more performant access and no need to have createAttachment or changes to current PR. do you think this is a solutions or did I misunderstand your concerns?

usage -

<div {@attach namedBaseAttachments.get('hover')} {@attach namedBaseAttachments.get('focus')} />

@paoloricciuti
Copy link
Member

I had an idea to possible help with having names for code base reuse of common attachment functions

-import { createAttachment } from 'svelte/attachments';

// named
-const namedBaseAttachments = { hover: node => {}, focus: node => {} }
-const namedDragAttachments = { drag: node => {}, dropzone: node => {} }
-const namedTooltipAttachments = { tooltip: node => {}, aria: node => {} }
-const namedBaseAttachments = { hover: createAttachment(node => {},) focus: createAttachment(node => {}) }
-const namedDragAttachments = { drag: createAttachment(node => {},) dropzone: createAttachment(node => {}) }
-const namedTooltipAttachments = { tooltip: createAttachment(node => {},) aria: createAttachment(node => {}) }
+const namedBaseAttachments = new Map([['hover', node => {}], ['focus', node => {}]])
+const namedDragAttachments = new Map([['drag', node => {}], ['dropzone': node => {}]])
+const namedTooltipAttachments = new Map([['tooltip', node => {}], ['aria', node => {}]])

@huntabyte, I know this is a little harder to read, maybe not so clear as Object but it would allow for more performant access and no need to have createAttachment or changes to current PR. do you think this is a solutions or did I misunderstand your concerns?

usage -

<div {@attach namedBaseAttachments.get('hover')} {@attach namedBaseAttachments.get('focus')} />

This is already possible with object destructuring...what he wants is be able to spread every except the destructured ones.

@Rich-Harris
Copy link
Member Author

Or is this about something else?

This is dangerous...

import { createAttachment } from 'svelte';

export function createCoolAttachments() {
  return {
    tom: createAttachment((node) => {...}),
    dick: createAttachment((node) => {...}),
    harry: createAttachment((node) => {...})
  };
}
<script>
  import { createCoolAttachments } from 'some-library';
</script>

<my-element {...createCoolAttachments()}>...</my-element>

...because <my-element> might have props called tom, dick and harry that are completely unrelated.

@paoloricciuti
Copy link
Member

However i just realised there's actually a way to do it (maybe not as nice as destructuring).

You can have this

import { on } from "svelte/events";

type Attachment = (node: HTMLElement) => unknown;

const attachments = {
    log(node) {
        console.log(node);
    },
    alert(node) {
        $effect(() => {
            return on(node, "click", () => {
                alert("clicked");
            })
        })
    },
    log_click(node) {
        $effect(() => {
            return on(node, "click", () => {
                alert("clicked");
            })
        })
    },
}  as const satisfies Record<string, Attachment>;

export function get_attachments(wanted: Array<keyof typeof attachments>) {
    const unique_want = new Set(wanted);
    const ret: Record<symbol, Attachment> = {};
    for (const want of unique_want) {
        ret[Symbol()] = attachments[want];
    }
    return ret;
}

and this would allow you to have autocompletion on the available attachments and be able to selectively spread every attachment on elements too.

@Leonidaz
Copy link

...because <my-element> might have props called tom, dick and harry that are completely unrelated.

but we are in the svelte codebase and this still goes through compilation, right? {...createCoolAttachments()} would be compiled with a spread wrapper function that would find all attachments by Symbol and just "attach" them just like it does for regular dom elements.

@Not-Jayden
Copy link
Contributor

Not-Jayden commented Jan 15, 2025

Excited to see this one get in 🥳 Some really nice additional power on top of actions, which are already pretty great as-is 😅

I do think I agree with some of the sentiment here that a greater separation of concerns between props and attachments might be a bit more intuitive/predictable in this case, rather than needing this additional concept of attachment keys/symbols that automatically get attached if they happen to be spread as props.

I was able to reason about the new onevent as props pattern for Svelte 5 easy enough, given how it maps to the actual HTML event attributes, but I think this addition of attachments is where the $props() rune is starting to feel over-stuffed with non-standard/non-prop-like behaviours.

I think in practice the solution to this would simply mean leaning into the {@attach} tag being the only way an attachment can be attached, and accept the tradeoff of needing to define props and attachments in separate objects/arrays, and do two spreads for something like:

<button {...props} {@attach ...attachments} />

An additional complexity to this I can think of, though, is for the component attachments use case. To have this stronger separation of props and attachments (i.e., attachments shouldn't be returned by $props()), it probably necessitates introducing some sort of additional rune or tag. This would serve the purpose of getting an array of all the attachments it receives and applying them to the target element(s) they are meant to be attached to. e.g.

<script>
    const attachments = $attachments();
</script>

<button {@attach ...attachments} />

Not super confident on what that syntax should be though.

@gterras
Copy link

gterras commented Jan 15, 2025

What about

<script>
        const attachments = $props({attachmentsOnly: true})
        const props = $props()

        const props_without_attachments = $props({attachments: false})
</script>

Would work with events too.

@JonathonRP
Copy link

I think the attachments as props and spreading is fine, I think others are discussing how to handle the programmatic creation of attachment functions and desiring to have string key names that handle the attaching rather than Symbol key to have reusable and composable attachment functions in repos/libs

@Rich-Harris
Copy link
Member Author

Attachments are not supposed to be distinct from props. That's the whole point — they're supposed to be something that an element wrapper component (Button.svelte) can throw onto its element without knowing or worrying about what they are.

There's no argument for separating attachments from props that couldn't apply just as well to event handlers or snippets (or insert-other-thing-you-can-make-a-case-for-being-ontologically-distinct). And indeed people made arguments for all sorts of things like let { onclick } = $events() and so on. Hopefully we can all see, now that Svelte 5 is in the wild, that it would have been a mistake to do that.

@JonathonRP
Copy link

JonathonRP commented Jan 15, 2025

...because <my-element> might have props called tom, dick and harry that are completely unrelated.

@Leonidaz, I think he means imagine if html gets new props in an update that allow new functionality whose name could also happen to be 'tom', 'dick' and 'harry' not that the names wouldn't be compiled out...

@gterras
Copy link

gterras commented Jan 15, 2025

I think the attachments as props and spreading is fine, I think others are discussing how to handle the programmatic creation of attachment functions and desiring to have string key names that handle the attaching rather than Symbol key to have reusable and composable attachment functions in repos/libs

Yes this was pretty much solved here I was reacting on $props() does a lot of thing.

Attachments are not supposed to be distinct from props. That's the whole point — they're supposed to be something that an element wrapper component (Button.svelte) can throw onto its element without knowing or worrying about what they are.

I agree with this choice but is there really need for having a choice to make after all? What's the downside of adding parameters to $prop and keeping default behavior?

And indeed people made arguments for all sorts of things like let { onclick } = $events() and so on. Hopefully we can all see, now that Svelte 5 is in the wild, that it would have been a mistake to do that.

I don't see where would the mistake be today with let { onclick } = $props({eventsOnly: true}) or let snippets = $props({snippetsOnly: true})? Just curious.

@Leonidaz
Copy link

...because <my-element> might have props called tom, dick and harry that are completely unrelated.

@Leonidaz, I think he means imagine if html gets new props in an update that allow new functionality whose name could also happen to be 'tom', 'dick' and 'harry' not that the names wouldn't be compiled out...

Basically, if svelte removes the passed in prop names and just attaches the attachments, but later the custom element ends up adding new attributes with these names?

Svelte still never passes in these props as they're attachments. If new ones are added to custom element this would result in nothing being passed in for these attributes, it's the same as for regular dom. But if these new attributes are now required then the person calling the custom element needs to adjust their code. This would be the case with any code.

Basically, named attachments are anonymous for dom or custom elements. They're not attributes. The names are only useable by components.

Maybe I'm misunderstanding something?

@Rich-Harris
Copy link
Member Author

I don't see where would the mistake be today with let { onclick } = $props({eventsOnly: true}) or let snippets = $props({snippetsOnly: true})? Just curious.

Aside from the fact that it's a bit of an ugly signature (I strongly dislike functions that return a different category of thing based on some parameter — it's usually emblematic of a flawed design), how would it even work? Do we assume that every component prop beginning with on is an event handler?

<Mirepoix carrot={5} celery={3} onion={7} />

Oops, that doesn't work — onion is a false positive. Maybe an event is something with an on prefix that's also a function... but what about things that are added later?

<script>
  let { interactive } = $props();
</script>

<Canvas onmousemove={interactive ? (e) => {...} : undefined} />

$props() is a one-time thing, so that doesn't work either.

The same applies to snippets and attachments, both of which can be set later, after the props have been defined.

@Leonidaz
Copy link

I read through the responses and I still think that named attachments have a lot of value in terms of usage and readability. It would be great to have a choice to use one or the other as needed.

Usability:

  • @huntabyte already made a good point about an ability to pick and choose attachments feat: attachments #15000 (comment) and perhaps a workaround can be created (as per @paoloricciuti feat: attachments #15000 (comment)), it could just be solved out of the box with with named attachments without anything extra.

  • As a component consumer, knowing that attachment functionality exists and used by me in a bunch of my components, and knowing the behavior that I want to accomplish, why do I need to use a different syntax for a named component prop vs just using a named attachment?

    So, now I have to know to send a simple function vs attachment directly even though I just really want an attachment.

    <Component 
      logger={(node) => {}}
    />

    instead of just using a named attachment directly.

    <Component 
      logger={attach((node) => {})}
    />

    And, as a component creator, I have to declare a prop (e.g. I'm applying various attachments to multiple elements) that takes a regular function instead of just defining it as an attachment in the first place where the intention and usage are absolutely clear to both the consumer and creator.

    <!-- Component.svelte -->
    <script>
      let { children, logger, drag, ...rest } = $props();
    </script>
    
    <div class="main" {...rest}>
      <div {@attach logger}>...</div>
      
      and why do I need to figure out whether to attach it or pass it through as is?
      <div><AnotherComponent {@attach drag} /></div>
    </div>
    
    VERSUS:
    
    <div class="main" {...rest}>
      <div {logger}>...</div>
      <div><AnotherComponent {drag} /></div>
    </div>

Readability (even if the component doesn't expect named params):

  • Named, very clear intent, consistent with other props and js usage, easy to scan and understand.

    <Component 
      class='myclass'
      onclick={() => {}}
      tooltip={attach((node) => {})}
      logger={attach((node) => {})}
      dragger={attach((node) => {})}
    />
  • Anonymous, they're like ghosts in the code, I'd have to spend time reading the code for each one to figure out what they're for. And this @attach in front of js is annoying (more on it below).

    <Component 
      class='myclass'
      onclick={() => {}}
      {@attach (node) => {}} 
      {@attach (node) => {}}
      {@attach (node) => {}}
    />

The {@attach} inside props syntax:

  • tbh, it's really strange as what we expect to be inside {} is just javascript, this {@} is kind of like a proposed decorator syntax but obviously it's nothing more than a label and when decorators come out, it will be even more confusing.

  • I also don't find it appealing inside { }, it makes the js code inside look ugly. I would prefer not to use it or not have it at all.

  • If people want to use anonymous props, they can do it via:

    const params = {[Symbol()]: attach((node) => {})}
    <Component
      {...params}
      {...
        {[Symbol()]: attach((node) => {})}
      }
    />

    And this usage would be exactly the same for dom elements

  • But if we really want to have to use syntactic sugar for anonymous attachments, while still allowing named ones, for readability and consistency with js and svelte, I'd prefer an attribute name with @ and to keep the inside of { } as js only. The @attach will just be compiled away with the current anonymous implementation.

    <Component
      {...params}
      @attach={(node) => {}}
      @attach={(node) => {}}
      @attach={(node) => {}}
    />
  • {@attach} "stand-alone" syntax is harder to understand for those who don't follow svelte that closely and would create more support work for maintainers and those who like to help out. I can guarantee that if left this way, we'll be getting a ton of questions, "why can't I do named={@attach (node) => {}}" or "please add named attachments". With the named attachment props and anonymous attribute syntax @attach={(node) => {}} it goes away.

Transitions:

  • I think transitions fall into a similar situation as attachments, not sure if the same approach can be used there. It sure seems that way. It'd be useful if transitions could also be named and would have their own Symbol on functions and this way svelte runtime would be easily able to distinguish between attachments and transitions functions based on ATTACH_SYMBOL vs TRANSITION_SYMBOL.

@Rich-Harris
Copy link
Member Author

Once again: this makes absolutely no sense. You simply would not expose a logger prop that expected an arbitrary attachment.

@JonathonRP
Copy link

are we not able to do,

<Button tooltip={@attach (node) => {}}/>

@Rich-Harris
Copy link
Member Author

No. You can do this if Button expects a tooltip prop (though see prior statement about this making no sense — if there's a tooltip prop then it's obviously going to accept some content or options, not a function for adding event listeners and stuff)...

<Button tooltip={(node) => {...}}>

...or this if it doesn't:

<Button {@attach (node) => {...}}>

@JonathonRP
Copy link

@Rich-Harris, oh! gotcha, thanks!
yeah thats true, the names make more sense for an object or something else that holds refrence to common used functions in the lib code base but not for props of component.
I'm excited to see how the api evolves once animations and transitions are included 👍

@Leonidaz
Copy link

Once again: this makes absolutely no sense. You simply would not expose a logger prop that expected an arbitrary attachment.

The first example was just to start a conversation about having to use regular function vs an attachment when it's not needed.

It goes on further to "distributing" props for multiple elements / nested components.

  • I have multiple elements and/or nested components
  • I need to use named props to assign them to the right elements / components
  • I don't need to wrap them in any way, because they already do what they need to do. They just need to be attached. E.g. various parent components have a different implementation of a logger, they just expect it to be attached to the right element.
  • I would be currently forced to use {@attach logger} and the caller would be forced to pass in a named prop (regular function) even though they want to provide a named attachment.

And, just to clarify, I completely agree with you that frequently a component just needs a callback prop and it will handle the attachment via wrapper. But it's not always the case.

There were also other points in my last comment about readability and syntax.

@webJose
Copy link
Contributor

webJose commented Jan 16, 2025

I have been waiting for something like this for some time in order to use it with floating-ui. If anyone is interested, this REPL shows a little bit about how attachments could be used in the context of a component library.

@dangelomedinag
Copy link

I was mentally thinking the following about Svelte syntax

{------ expression ------}
 decorator  |  expression
{@render        snippet()}
{@html             source}
{@debug          variable}

Then came {@const t = foo()} and I really felt like it was incorrect/inconsistent syntax.

{---------- declation ----------}
  keyword     | id | expression
no decorator
{@const         res = whatever()}

Now the syntax of {@attach ...} (amazing evolution of actions) inside html attributes, I think the syntax is not consistent, it is just "familiar".

{-- expression/attr/declaration --}
 attr?/decorator |  expression
{@attach          ()=>{}|fn()|id}

We remember some things that are no longer there with affection but ultimately were/are obstacles to moving forward.
bind:attr={}|use:foo={}|on:click|modifier={}|bind:this={}|in:scale|global={}|class:active
All similar, cannot be spread, inconsistent with each other and strange in some cases.

I imagine it more consistent (the symbol @ are details of little importance, it could be anything that identifies it as a pseudo/non-html attribute)

// psudo attrs
// future (with mechanisms for spreading)
@attach={}
@transition={}
@in={}
@out={}
@animate={}
@bind={} ??
//  """pseudo"""@decorator
{@render snippet()}
{@html source}
{@debug variable}
// control
{#const t = foo()}
{#snippet child()}
{#if cond}
{#each user as user}
{#await promise}

@Not-Jayden
Copy link
Contributor

Attachments are not supposed to be distinct from props. That's the whole point — they're supposed to be something that an element wrapper component (Button.svelte) can throw onto its element without knowing or worrying about what they are.

I think there's a need here to confirm the desired behaviour and limitations of component attachments. When you apply an attachment to a regular element, it's predictable that it will be attached directly to that element. However, treating attachments as props for the component case muddies this, as they could end up attached to any and all elements that exist inside the component.

It seems like for predictability, component attachments should only be able to be applied to a single element inside a component, but the current implementation doesn’t really have a way to enforce this.


There's no argument for separating attachments from props that couldn't apply just as well to event handlers or snippets (or insert-other-thing-you-can-make-a-case-for-being-ontologically-distinct). And indeed people made arguments for all sorts of things like let { onclick } = $events() and so on. Hopefully we can all see, now that Svelte 5 is in the wild, that it would have been a mistake to do that.

The introduction of the {@attach} tag to me felt like an intentional effort to distinguish attachments from regular props, though seemingly that wasn't the case. To me, attachments feel more distinct from events or snippets-as-props because they have an additional custom behaviour: they "attach" when spread onto an element, rather than just being assigned as an attribute or prop. If there is a desire to make attachments more distinct from regular props, it seems logical to also access them from a component in a distinct way with something like an $attachments() prop.

But also to completely backtrack on my previous suggestion of only being able to attach using the {@attach} tag, if the intention is instead for attachments to be conceptually grouped together with regular props, perhaps the whole {@attach} tag concept should go away entirely? Instead, lean into a reserved attachment key/symbol for denoting something as an attachment prop that will perform that "attach" behaviour.

For defining attachments inline, this could look something like what others have suggested above with the @attach keyword as a reserved prop:

<Button @attach={(node) => doSomething()} /> // `@attach` is a reserved prop keyword, which creates a new AttachSymbol() as the attachment prop key when used

This is probably the cleanest syntax for adding attachments directly on an element or component, and it also makes it somewhat clearer to associate an attachment as a prop. That said, this does feel like a somewhat dubious improvement on {@attach} as a syntax, and conflicts with my previous sentiment that there should ideally only be one way to make an attachment.

More in line with the idea of having only one way of doing something, perhaps then prop keys could instead be expanded to support computed property names, allowing the attach symbol to be created directly as the key to define an attachment instead. i.e.:

<Button [AttachSymbol()]={(node) => doSomething()} /> 

This is something you can already achieve with the current implementation when spreading props onto an object. e.g.

<Button {...{[AttachSymbol()]: (node) => doSomething()}} />

so it raises the question why it shouldn't also be achievable to do this directly on the prop key as well.


Separately but related, I’m not sure what the main motivator was for switching to Symbol() as the means of defining attachment keys from the original implementation, but it feels like a misstep.

Presumably, it was just for the global availability of Symbol() that avoids the need for an additional import? If so, I’m not convinced that’s worth sacrificing the explicitness or clarity of something like createAttachmentKey() or AttachSymbol() that returns a branded type and indicates it will behave as an attachment. Without being familiar with the Svelte docs, there's no intuitive way to understand that Symbol() properties have a special, re-appropriated meaning when spread as props.

Perhaps the global availability benefit could still be achieved by having something like $props.attach() for creating the key instead, seeing as the $props rune is already globally available, and it seems like a relatively intuitive place to access it from if attachments are props.

Whatever the method for creating attachment keys might look like aside, the current implementation feels like a one-way door—if there ever is a need or desire for symbols to be used as property keys for non-attachment purposes, the current behaviour would make that change extremely difficult, if not impossible.

@webJose
Copy link
Contributor

webJose commented Jan 16, 2025

There's still a lot of people seemingly not convinced of the current implementation, and I wonder if you guys have test-driven the current implemenation. I have: POC for floating-ui

After writing it, I don't think I have a single complaint.

  1. The attachment is fully reactive.
  2. I did not have to write effects or derived's; all reactivity comes from direct use of props.
  3. I was able to attach through a component + spreading.
  4. I had no need for isAttachmentKey. I still don't know what this would be for.

Furthermore, my expectations:

  1. I should be able to pass more {@attach}ments to the elements and components.
  2. If a component ever wants to receive a "named" attachment, I should be able to declare a prop: hoverAttach?: (node: HTMLElement) => void;. Then the component would do <div {@attach hoverAttach}>.
  3. For the above, an undefined value of the prop will be ignored.

Conclusion

I'm satisfied, I think, as a consumer and as a library creator. Right now, I cannot think of a single extra thing that I want.


let stuff = $state({
[Symbol()]: (node) => node.textContent = 'set from component'
});
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test should be enhanced to test that when removing the old symbol and adding a new one that the old one is cleaned up properly

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.