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

Svelte 5: Support passing of snippets or components? #9774

Open
brunnerh opened this issue Dec 5, 2023 · 30 comments
Open

Svelte 5: Support passing of snippets or components? #9774

brunnerh opened this issue Dec 5, 2023 · 30 comments
Milestone

Comments

@brunnerh
Copy link
Member

brunnerh commented Dec 5, 2023

Describe the problem

This came up in Discord:
It might be useful for library components to accept either a snippet or a regular component as input.

<!-- Snippet allows for adding additional content besides component/s -->
<List {items}>
	{#snippet item(data)}
		<ListItem {...data} />
	{/snippet}
</List>

<!-- Passing just a component -->
<List {items} item={ListItem} />

Describe the proposed solution

There would have to be a way to either:

  • Render a snippet or component regardless of what it is, e.g. make @render accept components.
    The first argument (in case Svelte 5: Variadic snippets #9672 is implemented) to the called function would then be considered the props in case of a component.
  • Add built-in functions to determine if something is a component/snippet. E.g.
    {#if isSnippet(item)}
      {@render item(args)}
    {:else if isComponent(item)}
      <svelte:component this={item} {...args} />
    {/if}

Alternatives considered

Always require the use of snippets, which can just use the desired component internally.
Potentially add a conversion utility from component to snippet:

<List {items} item={asSnippet(ListItem)} />

Importance

nice to have

@dummdidumm
Copy link
Member

I'm wondering how common this really is that it requires an ergonomic shortcut. Snippets are strictly inferior to components, so one could just do this

<List {items}>
	{#snippet item(data)}
		<ListItem {...data} />
	{/snippet}
</List>

@Conduitry
Copy link
Member

I agree. This feels like an additional thing to do at runtime (affecting everyone's components in all apps) for the sake of a weird use case, when the alternative is to just write a little bit more of more-explicit code.

@mimbrown
Copy link
Contributor

mimbrown commented Dec 5, 2023

It’s only an additional thing to do if snippets and components work differently, but currently they are pretty similar under the hood.

@brunnerh
Copy link
Member Author

brunnerh commented Dec 5, 2023

Another compromise would be a utility function for converting a component to a snippet so you could do something like:

<List {items} item={asSnippet(ListItem)} />

This should not affect anything else.

(Tried to make that work in user land but could not quite manage to do so.)

@Rich-Harris
Copy link
Member

It’s only an additional thing to do if snippets and components work differently, but currently they are pretty similar under the hood.

The key word here is 'currently'. Things will doubtless evolve in future (#9672 is an example of a change we might make, albeit one that probably wouldn't affect this sort of interoperability), and it's very likely that we'd come to regret doing this. That's enough of a reason for me to oppose this proposal, but beyond that I don't think it's desirable anyway — I've often regretted these kinds of loosey-goosey APIs. Prefer clarity and consistency over convenience, in almost all cases.

@mimbrown
Copy link
Contributor

mimbrown commented Dec 6, 2023

Totally fair to want to preserve the ability to iterate. But over time I wouldn't be surprised if they naturally converged. I would argue that if snippets and components became closer over time, it would be more consistent, and indicate that you've got some kind of natural contract emerging that is the right one for Svelte.

In my head, the difference between a Component and a snippet is pretty similar to the difference between a function defined at the top-level and one defined in some nested scope. Array.map is not a "loosey-goosey API" for not caring where/how the passed function was defined.

Here's my REPL with a component and snippet side-by-side. Looking at the compiled output, it looks like the only difference is the way args are passed, and I would say it kinda looks like the snippet could actually benefit from being treated more like the Child component in the way args are passed. No duplicate creation of the passed object, for one.

And I have to go here because it's the natural next question: do you need @render at all? Could it just be:

{#snippet MySnippet({ arg1, arg2 })}
  <div class={arg1}>{arg2}</div>
{/snippet}

<MySnippet arg1="foo" arg2="Bar" />

@mimbrown
Copy link
Contributor

mimbrown commented Dec 6, 2023

To be clear: if the only way to implement this proposal is to have code that looks like this:

return isSnippet(thing) ? renderSnippet(thing) : renderComponent(thing)

then please don't do it. It works only if they're naturally interoperable. That is a loosey-goosey API.

@dummdidumm
Copy link
Member

In #9903 it was asked to not only differentiate between snippets and components, but also between those two and regular functions - i.e. isComponent and isSnippet functions.

@zhuzhuaicoding
Copy link

{#snippet actionsDom()}
{#if props.actionsRender === false}
return
{/if}
dom = <Actions {actions} {editing} {placement} {type} />;
{props.actionsRender(restProps, dom) || dom}
{/snippet}

can snippet support this syntax?

@jacob-8
Copy link

jacob-8 commented Apr 15, 2024

For the purposes of my component mocking tool, it would be great to be able to pass in a component as the argument for a snippet prop, because the props are written in Typescript. If {@render foo()} syntax isn't able to accept a component if it makes Svelte too complicated, I'm fine with that but would at least love a solution like this:

Another compromise would be a utility function for converting a component to a snippet so you could do something like:

<List {items} item={asSnippet(ListItem)} />

Then I could build up a variant like this:

import MockAvatar from './MockAvatar.svelte'

export const myVariant = {
  name: "Bob",
  image: asSnippet(MockAvatar),
}

Rich-Harris added a commit that referenced this issue May 15, 2024
* feat: provide isSnippet type, deduplicate children prop from default slot

fixes #10790
part of #9774

* fix ce bug

* remove isSnippet type, adjust test

* fix types

* revert unrelated changes

* remove changeset

* enhance test

* fix

* fix

* fix

* fix, different approach without needing symbol

---------

Co-authored-by: Rich Harris <[email protected]>
@sheijne
Copy link

sheijne commented Jun 17, 2024

I would love to have isSnippet and isComponent utilities as a "low-level" api.

{#if isSnippet(item)}
  {@render item(args)}
{:else if isComponent(item)}
  <svelte:component this={item} {...args} />
{/if}

As an example I currently have a component which is an abstraction similar to the Slot component from radix-ui. It accepts an as prop, which can be a Snippet or a string, I have not been able to figure out a way to differentiate between snippets and components, otherwise it would accept a component as well. It would be great if it could also accept a component as well. A little dumbed down, it looks like this:

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

{#if typeof as === 'function'}
  {@render as(props, children)}
{:else if typeof as === 'string'}
  <svelte:element this={as}>{@render children?.()}</svelte:element>
{:else}
  {@render children?.()}
{/if}

Being able to turn the above into something like this would be amazing:

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

{#if isSnippet(as)}
  {@render as(props, children)}
{:else if isComponent(as)}
  <svelte:component this={as} {...props}>{@render children?.()}</svelte:component>
{:else if typeof as === 'string'}
  <svelte:element this={as}>{@render children?.()}</svelte:element>
{:else}
  {@render children?.()}
{/if}

@bdmackie
Copy link

+1 for an isSnippet / isComponent function to support more flexible reusable components. With filename disappearing, I started looking at function names and even tempted by scanning fn.toString but of course that's brittle.... appreciate advice on a less brittle workaround if this won't make the v5 cut. Cheers.

@Bishwas-py
Copy link

Bishwas-py commented Aug 2, 2024

Here's a simple work around I've found.

type Props = {
		icon: SvelteComponent | Snippet
	};
	let {
		icon = ChevronDown
	}: Props = $props();

<button class="dropdown-title" {onclick}>
		{#if icon && icon.length === 1}
			{@render icon()}
		{:else if icon}
			<svelte:component this={icon} class="w-4" />
		{/if}
		<span>{title}</span>
	</button>

icon.length === 1 for Snippet and icon.length === 2 for Component

@jacob-8
Copy link

jacob-8 commented Aug 3, 2024

icon.length === 1 for Snippet and icon.length === 2 for Component

Assuming this continues to hold true and with the addition of createRawSnippet and Rich's demo of using it to turn a component into a snippet I believe this issue can be closed. Here are all the helpers we need:

function isSnippet(item: Component | Snippet) {
  return item.length === 1
}

function isComponent(item: Component | Snippet) {
  return item.length === 2
}
import { createRawSnippet, hydrate } from 'svelte';
import { render } from 'svelte/server';

function componentToSnippet(Component: Component) {
  return createRawSnippet((props_function) => { 
    const props = props_function ? props_function() : {};
    return {
      render: () => `<div>${browser ? '' : render(component, { props }).body}</div>`, 
      setup(target) { 
        hydrate(component, { target, props }) 
      } 
    }
  });
}

See them demoed here. <<- edited to use the Component type and <Item /> syntax for dynamic components.

@bdmackie
Copy link

bdmackie commented Aug 3, 2024

@Bishwas-py , @jacob-8 See:

The change from classes towards functions is also reflected in the typings: SvelteComponent, the base class from Svelte 4, is deprecated in favour of the new Component type which defines the function shape of a Svelte component.
Source: https://svelte.dev/docs/svelte-components

Have you found something that works with the new Component type?

@jacob-8
Copy link

jacob-8 commented Aug 3, 2024

Have you found something that works with the new Component type?

Sorry, my naive oversight. I only did that demo in the repl to explore something I want to use in the future. I don't have any Svelte 5 projects yet due to an incompatibility I'm waiting on so I'll not dig further at the moment. But I'm sure it wouldn't be hard for someone using Svelte 5 in VS Code with intellisense running to dig into and show us the proper way to type that helper function.

@bdmackie
Copy link

bdmackie commented Aug 3, 2024

OK so the length check does work in Svelte 5 (runes mode)... the runtime check being more important than the type system of course. It also works both in SSR and client render. I also used the fancy new way of rendering a component directly, note the upper case first character of the prop name which is required for this to work.

export const isComponent = (value: Component | Snippet): value is Component =>
	value.length === 2;

export const isSnippet = (value: Component | Snippet): value is Snippet =>
	value.length === 1;
<script lang="ts">
  import { isComponent, isSnippet } from './helpers'

	// Props
	type Props = {
		name: string
		Icon: Component | Snippet;
	};
	let { name, Icon }: Props = $props();

</script>

{#if isComponent(Icon)}
	<p>{name} Component:</p>
	<Icon />
{/if}

{#if isSnippet(Icon)}
	<p>{name} Snippet:</p>
	{@render Icon()}
{/if}

Demo

Thanks for the suggestion.

@juho
Copy link

juho commented Aug 9, 2024

I have a component called StringOrComponentOrSnippet that I use in a lot of other components, allowing for a string, component or a snippet to be passed in. It's very handy in places where I do containment of child content that may be anything, like in menus, forms, etc. IMO createRawSnippet doesn't feel like the correct solution to this problem, which I've independently solved in the manner bdmackie has. It would be nice if there were official helper functions.

@dummdidumm
Copy link
Member

What is the use case for allowing any of these things? Can these problems be solved by designing the component API in question differently, for example requiring components and snippets be passed through different props?

@blujedis
Copy link

blujedis commented Aug 20, 2024

For those from React you may remember many of us had helpers to check if a Prop was a React Component, React Element, string etc.

Now this is just my .02, but it's early. Perhaps a bit more time should pass before adding in these helpers officially? Maybe a little documentation/examples would suffice?

That said I made a Conditional component which works similar to what @bdmackie has done. I feed it the prop from the parent and it sorts this out. So its a one time helper component, use as needed.

I think where you run into this most is when you're trying to make things Generic or its in a Lib and you want to allow the user to pass in their own Snippet/Component or something.

@bdmackie
Copy link

bdmackie commented Aug 21, 2024

Yes the use case is for library / design systems. My motto for most abstractions is to "make the easy things easy and the harder things possible."

Using the example of my stackblitz link above, imagine an button component that can display some extra content on the side. Typically that would be an icon, and in that case passing an icon component directly has the best ergonomics:

<Button LeftIcon={MyCoolIcon} />

However if more content control is required, a snippet works well:

{#snippet MyBigIcon()}
  <MyBigIcon size="xl" />
{/snippet}
<Button LeftIcon={MyBigIcon} />

The alternative might be to have separate properties for components and snippets, which gets bloated pretty quickly in a reusable component.

The other alternative would of course be to just always use snippets. Unfortunately snippets add a lot of code to a page when they are being used for both common case and escape hatch requirements.

An aside: while writing this I thought of another alternative which is to penalise the snippet form using an object property type. That'd be more resilient to svelte framework changes but degrades snippet form ergonomics:

{#snippet MyBigIcon()}
  <MyBigIcon size="xl" />
{/snippet}
<Button LeftIcon={{snippet: MyBigIcon}} />

I feel conceptually snippets and components meet overlapping use cases enough to warrant an explicit check, however if no helpers appear and I get back to that area I may just test the approach in the aside above.

@blujedis
Copy link

I agree @bdmackie. Working through same concepts. My point to saying it's "early" was more or less to suggest that at some point some use case usually comes along and forces the hand one way or another.

Building out a Notifications/Snackbar controller component at the moment. I could make a case for either pattern to be fair. While it is more verbose, using a snippet is certainly more flexible and gives more latitude. Snippets don't look as clean, particularly for a lib, but perhaps we should just get over that. Not campaigning either way here just speaking out loud...

Of course those feelings could change after I build this out LOL.

@Rich-Harris
Copy link
Member

Now this is just my .02, but it's early

This is where I land too. Obviously it's possible to come up with use cases for isSnippet and isComponent, but personally I've always found that stricter and more explicit APIs (that render these sorts of checks unnecessary) stand the test of time much better. People will sometimes invoke Postel's law in these discussions...

be conservative in what you send, be liberal in what you accept

...but I've always found that to be questionable advice at best and actively harmful when it comes to API design.

It's always possible to design a more explicit API. To take the icon example: rather than allowing it to be either a component or a snippet, you could expose two separate props (and perhaps throw an error if both are provided):

<Button Icon={MyCoolIcon}>
  button text
</Button>

<Button>
  {#snippet icon()}
    <MyBigIcon size="xl" />
  {/snippet}

  button text
</Button>

The alternative involves adding things like private symbol properties to functions and checking for them — this adds overhead, and makes things less composable (or forces us to create new APIs for programmatically creating components, when today you can do wacky things with function composition).

As such it feels unlikely that we'd go down this road, so I'm personally inclined to close this issue but at the very least I'm going to move it off the list of issues blocking a 5.0 release.

@Rich-Harris Rich-Harris modified the milestones: 5.0, 5.x Aug 21, 2024
@bdmackie
Copy link

bdmackie commented Aug 22, 2024

My resolve waned when I realised there were alternatives in my post so I'm good personally. Your team has "bigger fish to fry". (edit: a bit presumptuous of me the OP may want to respond).

I appreciate the time you took in commenting on API design. I'm still coming to grips with Svelte's 'leadings' on API boundaries. Yes there's a choice to many-property however that smelt a bit on both sides (inside and out) to me, perhaps it is because JavaScript/TypeScript embraces polymorphism so it feels odd to step away from it in API design.

Totally agree re Postel's law, to borrow a cooking analogy, "you can add more salt but you can't take it away" (well my Nan had a trick with potatoes but I digress). Ironically if the API gives freedom to put any kind of content in children content then the library author has the same problem with little control over rendering if semantics are present. I've wondered if being able to analyse children content would help with more explicit composable APIs, which might mean a win for the lovers of declarative style. I can imagine there have been thoughts on that in the team.

Since I first posted I've also come to learn more about svelte actions and how they break out into objects-as-attributes, which I think is why upon returning I thought of a similar pattern here. That seems to smell a bit too, however organisation of that can be pulled up into the script section if it gets unwieldy, which is still kinda declarative (slightly looks to the side).

@juho
Copy link

juho commented Sep 4, 2024

As the symbol for the snippet was removed, now the only choice is to do a toString() on the function and perform some heuristics. I feel like this choice may be passing down the loosey-goosey API for library developers to implement as separate props for snippets and components. Besides the icon mentioned above, having the functionality to differentiate between snippets and components would be useful in any declarative situation. Declaratively made menus are another good example:

{#snippet item()}
  some adhoc content
{/snippet}
<MenuComponent items={[
  { type: "header", content: SomeRepeatedComponent }
  { type: "item", content: item }
 ]} />

Ultimately I would only need a way to render either a component or a snippet coming from a single prop as it's not used for anything else. Having to inspect the function is a crutch, but I like separating component and snippet props less. If render() didn't care if it was rendering a component or a snippet, this would be moot and branching in userland based on the prop type wouldn't be needed at all. I wonder if this would be possible down the road?

@pauldemarco
Copy link

pauldemarco commented Sep 12, 2024

My resolve waned when I realised there were alternatives in my post so I'm good personally. Your team has "bigger fish to fry". (edit: a bit presumptuous of me the OP may want to respond).

I would consider the mechanics of how UI gets passed around a pretty big fish for Svelte 5.
Snippets vs Components will be a very frequent topic as people adopt, especially coming from other frameworks where this is a single entity (like a Widget in Flutter).

@bdmackie
Copy link

bdmackie commented Sep 25, 2024

To be honest I had no real idea about the size of fish involved :) Perhaps it was better to say it felt like 'the ship had sailed' given snippets had been built for a while and it felt the release of Svelte 5 was/is imminent.

Perhaps they missed a trick by not unifying when they designed Snippets, or have other constraints we don't know about. I imagine the design of Snippets was considered to be "the new slots" rather than something all-encompassing. Overall I agree it's strange the concepts are separate and that we bear the burden of differentiating them. I suspect the end result will be a fragmentation of extensibility approaches used in the ecosystem.

I notice there is a createRawSnippet function so I wonder if there's a way to build your own 'render either' utility? the component render function only works on the server I think so it could blow up on the client side?

@jacob-8
Copy link

jacob-8 commented Sep 27, 2024

I notice there is a createRawSnippet function so I wonder if there's a way to build your own 'render either' utility? the component render function only works on the server I think so it could blow up on the client side?

I already provided all the code needed for that above - especially look inside the demo at lines 61-67. You just need to grab the pieces needed from there for your use case. There is no render blow up on client because the example usage (first given by Rich and then modified to work with props) uses render on the server and hydrate on the client.

@bdmackie
Copy link

bdmackie commented Sep 29, 2024

@jacob-8 I again circled back to this (it's the gift that keeps on giving) but I noticed your function inserts an additional <div> around the component. If that's true I'd say we're all still just hacking here.

@jacob-8
Copy link

jacob-8 commented Sep 29, 2024

@jacob-8 I again circled back to this (it's the gift that keeps on giving) but I noticed your function inserts an additional <div> around the component. If that's true I'd say we're all still just hacking here.

Haha, yes we are. Try the <div style="display: contents"></div> hack that SvelteKit itself employs to keep that extra div from causing mischief.

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

No branches or pull requests