Skip to content

feat: Variadic snippets #9988

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

Merged
merged 88 commits into from
Jan 31, 2024
Merged

feat: Variadic snippets #9988

merged 88 commits into from
Jan 31, 2024

Conversation

elliott-with-the-longest-name-on-github
Copy link
Contributor

A REPL showing that this handles every case I could think of

Summary

This would close #9672, assuming we want it. I kind of like it!

This allows snippets to have multiple arguments in the type system. For example, you could type a component expecting a two-argument snippet as:

<script lang="ts">
  let {
    message,
    card
  } = $props<{
    message: string;
    card: Snippet<[message: string, darkMode: boolean]>;
  }>(); 
</script>

{@render card(message, false)}

Since spreading is also now acceptable, you could also:

{@render snippet(...['as', 'many', 'strings', 'as', 'i', 'want'])}

The only gotcha being that spreading a $state array would lose reactivity unless the items are boxed state.

Implementation notes:

Adding additional arguments was easy -- basically just improving the parsing for snippet blocks, removing a "one arg only" check in one place, and sticking loops in some other places.

Adding support for rest params and spread arguments was harder. In the case of spread arguments, a function call like this:

foo(...bar)

is a problem for us, because when compiling calls to snippet functions, we turn each argument into a thunk. We obviously can't thunk bar in this case:

foo(...() => bar)

because it would explode. The only solution I could come up with was to basically deferred-thunk rest arguments, so:

// this...
foo(1, 2, ...rest)

// ...becomes this
foo(() => 1, () => 2, ...$.thunkspread(rest))

// ...where `thunkspread` is just:
export function thunkspread(iterable) {
  const thunks = [];
  for (const item of iterable) {
  thunks.push(() => item);
  }
  // @ts-expect-error -- for obvious reasons
  return thunks;
}

I don't think this is any significant performance penalty; JavaScript would already have to turn any spread iterable into an array anyway, so basically all we're doing is the additional runtime thunkification. It'd be worth testing, though, to make sure we're not significantly regressing.

In the case of rest params, the problem was even more complicated. A "normal" snippet gets compiled like this:

{#snippet foo(one, { two })}
  <div>{one}, {two}</div>
{/snippet}

becomes:

function foo($$anchor, one, $$arg1) {
  let two = () => $$arg1().two;
  /* Init */
  var div = $.open($$anchor, true, frag_1);
  var text = $.child(div);
  
  /* Update */
  $.text_effect(text, () => `${$.stringify(one())}, ${$.stringify(two())}`);
  $.close($$anchor, div);
}

Basically, we make sure that all of the arguments to the function are accessible by their original names -- for non-destructured arguments, we do nothing; for destructured arguments, we find all of the destructured identifiers and thunkify them into variables that can then be called elsewhere (as you can see with one() and two().

With rest params, we lose the ability to do this -- because ...rest is an array, we can't anticipate when or where the actual items inside will be accessed, and therefore can't make sure their thunkified values are called. rest[0] would result in an uncalled thunk rather than the value expected!

The only solution I could come up with for this was to wrap the rest array in a super simple proxy:

export function proxy_rest_array(items) {
  return new Proxy(items, {
    get(target, property) {
      if (typeof property === 'symbol') return target[property];
      if (!isNaN(parseInt(property))) {
      return target[property]();
      }
      return target[property];
    }
  });
}

This basically says "if you try to access a numeric property on this array, I'll return the value behind the function at that index" -- it's the exact opposite of thunkspread. This also works with other array methods like find and map, which have to use numeric property accessors to get their values out of the array.

This enables the compiler to do this:

<script>
  let two = $state(2);
</script>
<button onclick={() => two++}>Update</button>
{#snippet test(one, two, three, {four, five}, ...six)}
  {@const testElement = six.find((_, i) => i === 0)}
  <div>{four}, {five}, {two}, {three}, {one}, {six[0]}</div>
{/snippet}
{@render test(...[1, two, 3, {four: 4, five: 5}])}

becomes this:

  function test(
    $$anchor,
    one,
    two,
    three,
    $$arg3,
    ...$$arg4
  ) {
    let four = () => $$arg3().four;
    let five = () => $$arg3().five;
    let six = $.proxy_rest_array($$arg4);
    /* Init */
    var div = $.open($$anchor, true, frag_1);
    const testElement = $.derived(() => six.find((_, i) => i === 0));
    var text = $.child(div);
    
    /* Update */
    $.text_effect(text, () => `${$.stringify(four())}, ${$.stringify(five())}, ${$.stringify(two())}, ${$.stringify(three())}, ${$.stringify(one())}, ${$.stringify(six[0])}`);
    $.close($$anchor, div);
  }

Is there a performance penalty? Yes, but crucially, only for rest params -- other params are unaffected. And in practice, I'm not sure how big that penalty actually is over the penalty faced by normal rest params.

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.

Tests and linting

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

Copy link

changeset-bot bot commented Dec 23, 2023

🦋 Changeset detected

Latest commit: 84c6eda

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

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

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

Copy link

vercel bot commented Dec 23, 2023

The latest updates on your projects. Learn more about Vercel for Git ↗︎

Name Status Preview Comments Updated (UTC)
svelte-5-preview ✅ Ready (Inspect) Visit Preview 💬 Add feedback Jan 15, 2024 11:30pm

@Rich-Harris
Copy link
Member

@tcc-sejohnson at one stage we had planned to make the [...] unnecessary in the common case of a single argument. I gather from #9672 that we abandoned that because of rest arguments, but given that we ended up not supporting them, does that still hold? Or are you still hoping that we can somehow make them work in future?

@elliott-with-the-longest-name-on-github
Copy link
Contributor Author

@tcc-sejohnson at one stage we had planned to make the [...] unnecessary in the common case of a single argument. I gather from #9672 that we abandoned that because of rest arguments, but given that we ended up not supporting them, does that still hold? Or are you still hoping that we can somehow make them work in future?

I think it's just... slightly uncanny the other way, and shrug no reason to prevent ourselves from doing rest args in the future if we want to.

Copy link
Member

@Rich-Harris Rich-Harris left a comment

Choose a reason for hiding this comment

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

almost done with review, just taking a break for dinner

@Rich-Harris
Copy link
Member

struggling a bit with the CI failure. can't reproduce locally. that's the only thing blocking merging right now

@Rich-Harris
Copy link
Member

actually scratch that, I can reproduce it. haven't figured it out yet though it does appear to be related to recent changes to {@const ...} parsing

@Rich-Harris Rich-Harris merged commit d309a9d into main Jan 31, 2024
@Rich-Harris Rich-Harris mentioned this pull request Jan 31, 2024
5 tasks
@dummdidumm dummdidumm deleted the elliott/variadic-snippets branch January 31, 2024 11:02
dummdidumm added a commit that referenced this pull request Jan 31, 2024
The generated types were invalid TypeScript ("cannot use unique symbol at this position"). Use utility types to ensure the types are not unpacked during type generation.
Leftover from #9988
trueadm pushed a commit that referenced this pull request Jan 31, 2024
* give this another try

* fix: lint

* fix: Forgot to save

* feat: it works boiiii

* look, ok, it did work, i just needed to update the snapshots

* bruh

* changeset

* feat: ok I think the client snippet block finally works

* feat: current tests pass; I'm sure I'm missing stuff for new things

* fix: snapshot

* feat: I think non-destructured rest should work now?

* chore: duplicated computation

* feat: Tests (passing and failing

* feat: it's... alive?

* chore: Clean up my messes

* chore: devtime stuff

* fix: fmt

* chore: see if this fixes repl

* chore: make naming more offensive

* fix: Don't throw on missing keys, return undefined as it usually would

* Update packages/svelte/src/compiler/phases/1-parse/state/tag.js

Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com>

* Update packages/svelte/src/compiler/phases/1-parse/state/tag.js

Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com>

* fix: Hopefully default param values now work

* dumb

* types

* feat: Test it

* fix: Turns out javascript parameters are optional

* feat: The Final Solution

* document function

* feat: Better bracket matching, unit tests

* feat: exclude test files from publish

* feat: More unit tests

* feat: Use more efficient parsing for @const

* Update .changeset/curvy-cups-cough.md

Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com>

* Update packages/svelte/package.json

Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com>

* Update packages/svelte/src/compiler/phases/1-parse/utils/bracket.js

Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com>

* fix: changesets

* chore: additional comments

* fix: kill foreach

* fix: foreach again

* feat: Docs

* Revert "fix: kill foreach"

This reverts commit 9a688cc.

* fix: My own stupidity

* fix: style

* fix - maybe

* Update sites/svelte-5-preview/src/routes/docs/content/01-api/03-snippets.md

* Update tag.js

Co-authored-by: Rich Harris <richard.a.harris@gmail.com>

* Update .changeset/curvy-cups-cough.md

Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com>

* chore: Remove rest params

* Delete .changeset/eighty-rivers-wash.md

* fix: Honestly idk why it was broken but it's fixed now

* fix: var name lol

* fix: typegen

* fix: idk

* fix: It looks like a bunch of unformatted shit came in through main?? idk

* Revert "fix: It looks like a bunch of unformatted shit came in through main?? idk"

This reverts commit ab851d5.

* fix: format again

* this is getting ridiculous

* Update tag.js

Co-authored-by: Rich Harris <richard.a.harris@gmail.com>

* fix errors

* simplify a bit

* use read_context

* use read_context for const as well

* remove unused code

* unused import

* unused export

* remove spread args. sorry elliott

* tidy up SnippetBlock interface

* fix test

* simplify

* tweak

* revert example, so that it matches the surrounding text

* move PropsWithChildren back to public.d.ts

* update typing docs, so that it flows from previous example

* temporarily revert const parsing changes, to get prettier working again (???)

* oops

---------

Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com>
Co-authored-by: Rich Harris <richard.a.harris@gmail.com>
Co-authored-by: Rich Harris <rich.harris@vercel.com>
trueadm pushed a commit that referenced this pull request Jan 31, 2024
The generated types were invalid TypeScript ("cannot use unique symbol at this position"). Use utility types to ensure the types are not unpacked during type generation.
Leftover from #9988
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.

Svelte 5: Variadic snippets
5 participants