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

Allow #each block to handle arbitrary non array like iterables #7425

Closed
joeally opened this issue Apr 7, 2022 · 25 comments
Closed

Allow #each block to handle arbitrary non array like iterables #7425

joeally opened this issue Apr 7, 2022 · 25 comments

Comments

@joeally
Copy link

joeally commented Apr 7, 2022

Describe the problem

Consider the following REPL:

<script>
	const myMap = new Map();
	myMap.set('key1', 'value1');
	myMap.set('key2', 'value2');
	myMap.set('key3', 'value3');
	for(const [k, v] of myMap.entries()){
		// Javascript lets us iterate through a set so I don't see why svelte shouldn't
		console.log(k, v);
	}
</script>
<!-- This is not allowed because mySet does not have `.length` -->
{#each myMap.entries() as [k, v]}
	<h1>Key: {k}, value: {v}</h1>
{/each}

Obviously one could trivially achieve this by wrapping myMap in Array.from or spreading into an array but it would be nice if svelte could handle iterables in addition to ArrayLike objects.

Describe the proposed solution

The problem is caused by the fact that svelte generates the code which loops over the each value using its .length property. This can be seen below:

// from top of create_fragment
let each_1_anchor;
let each_value = /*myMap*/ ctx[0].entries;
let each_blocks = [];

for (let i = 0; i < each_value.length; i += 1) {
	each_blocks[i] = create_each_block(get_each_context(ctx, each_value, i));
}

and

// from the p function returned by create_fragment
for (i = 0; i < each_value.length; i += 1) {
	const child_ctx = get_each_context(ctx, each_value, i);
	if (each_blocks[i]) {
		each_blocks[i].p(child_ctx, dirty);
	} else {
		each_blocks[i] = create_each_block(child_ctx);
		each_blocks[i].c();
		each_blocks[i].m(each_1_anchor.parentNode, each_1_anchor);	
	}
}

As you can see it assumes that each_value (in this case myMap) has a length property. It seems to me that we could easily generate the following code which is equivalent:

// the top of create_fragment
let each_1_anchor;
let each_value = /*myMap*/ ctx[0].entries();
let each_blocks = [];
for (const item of each_value) {
	each_blocks.append(create_each_block(get_each_context(ctx, item)));
}

// the loop in the p function generated by create_fragment
let each_value_i = 0;
for (const item of each_value) {
	const child_ctx = get_each_context(ctx, item);
	if (each_blocks[each_value_i]) {
		each_blocks[each_value_i].p(child_ctx, dirty);
	} else {
		each_blocks[each_value_i] = create_each_block(child_ctx);
		each_blocks[each_value_i].c();
		each_blocks[i].m(each_1_anchor.parentNode, each_1_anchor);	
	}
}

With get_each_context rewritten to be:

function get_each_context(ctx, item) {
	const child_ctx = ctx.slice();
	child_ctx[1] = item;
	return child_ctx;
}

It's worth noting that the for...of syntax isn't supported by IE11 but the svelte compiler appears to recommend the use of the spread operator which also isn't supported in IE11 so perhaps this isn't an issue.

Alternatives considered

The alternative here is to leave it as it is and tell people to use Array.from and or [...mySet]. It isn't the end of the world.

But given that it seems so trivial to support arbitrary iterables and that javascript can loop over iterables I can't see why svelte wouldn't implement this. Is it because you want any infinite loops to be in user code rather than generated code?

Importance

nice to have

@Pika-Pool
Copy link

Rich Harris has recently talked about supporting this too: https://youtu.be/dB_YjuAMH3o?t=4m18s

@stordahl
Copy link
Contributor

Would love to see this added to the roadmap for v4

@dummdidumm dummdidumm added this to the 4.x milestone Feb 27, 2023
@brandonmcconnell
Copy link
Contributor

brandonmcconnell commented Mar 8, 2023

@joeally Just confirming— once implemented, this wouldn't even require using .entries(), right?

I'd think this could be as simple as—

<script>
  const myMap = new Map();
  myMap.set('key1', 'value1');
  myMap.set('key2', 'value2');
  myMap.set('key3', 'value3');
</script>

{#each myMap as [k, v]}
  <h1>Key: {k}, value: {v}</h1>
{/each}

@stordahl
Copy link
Contributor

stordahl commented Mar 8, 2023

@brandonmcconnell @joeally What Brandon has provided is the API I would love to see. IMO Svelte should obfuscate the implementation details so the template reads the same whether you're iterating over a map or an array.

@dummdidumm
Copy link
Member

What will likely happen is that the each block calls Array.from under the hood

@Conduitry
Copy link
Member

Yeah, we need to buffer the whole iterable into a single array anyway to implement the keyed-each minimal edit stuff, so we might as well just use Array.from() on whatever's send to the {#each}. As a bonus, this works with { length: x } object as well, so we don't need special handling to avoid breaking that idiom we've promoted in the past.

@brandonmcconnell
Copy link
Contributor

brandonmcconnell commented Mar 8, 2023

Doesn't that defeat the performance benefit of Set and Map, though, when dealing with massive data sets?

In native JS, we can iterate over sets and maps using for..of and retain those perf benefits without needing to port their contents to an array. It would be ideal to keep those benefits.

const myMap = new Map();
myMap.set('key1', 'value1');
myMap.set('key2', 'value2');
myMap.set('key3', 'value3');

I ran some benchmarks just to demonstrate this difference, and Map iteration is faster across all (by over 50%):

JSBench.me results Perflink results JSBEN.CH results
for..of(Map) fastest fastest fastest
for..of(Array.from(Map)) 62.03% slower 🔻 ~60% slower 🔻 62.06% slower 🔻
for..of(Array.from(Map.entries())) 63.42% slower 🔻🔻 ~62% slower 🔻🔻 63.63% slower 🔻🔻
link to test link to test link to test
image image image

The fact alone that taking advantage of the perf benefits is so attainable and not something other frameworks have done yet is a huge opportunity for Svelte, and I think we'd be falling short to not take a key stake in big (and attainable) performance wins like this.

I think for..of loops require an iterable anyway, so by swapping to a for..of loop here, you'd naturally be supporting both Maps and Sets and other iterable data structures.

@Conduitry
Copy link
Member

The DOM is slow, and I am almost certain that losing the ability to do minimal DOM updates would far outweigh what we'd gain by only iterating through the array once with for of. Using for of internally would also mean that we could no longer directly support iterating over the array-like object { length: 42 }, which is something we've encouraged people to do in the past when all they really care about is the number of times something appears in the DOM. We would probably want to have something like 'length' in foo ? foo : Array.from(foo) so we don't lose performance for things that already are arrays or other array-likes.

@brandonmcconnell
Copy link
Contributor

brandonmcconnell commented Mar 9, 2023

I agree that a solution like 'length' in foo ? foo : Array.from(foo) would be ideal, though I think we could still preserve the performance benefits of Maps and Sets with minimal effort.

What "ability to do minimal DOM updates" would be lost by switching to use for..of loops?

If we use a lightweight abstraction like the iterateOver function below, we can keep all the benefits you mentioned and also support all iterables (e.g. maps, sets, generators, arrays, etc.) and array-likes.

Implementation

— see on TS Playground

function isIterable<T>(obj: Iterable<T> | ArrayLike<T>): obj is Iterable<T> {
  return typeof (obj as Iterable<T>)[Symbol.iterator] === 'function';
}

function iterateOver<T>(obj: Iterable<T> | ArrayLike<T>, expose: Function) {
  if (isIterable(obj) && !Array.isArray(obj)) {
    /* non-array iterables (array iteration using a traditional `for` loop is more performant) */
    let i = 0;
    for (const elem of obj) {
      expose(elem, i++);
    }
  } else {
    /* arrays and array-likes */
    const indexedObj: ArrayLike<T> = (
      'length' in obj && typeof obj.length === 'number'
        ? (obj as { length: number })
        : Array.from(obj)
    );
    for (let i = 0; i < indexedObj.length ?? 0; i += 1) {
      expose(indexedObj[i], i);
    }
  }
}

In ☝🏼 this example, I am using an imaginary "expose" function, but that would just be replaced by whatever logic or helper function you use to slot in the HTML for each {#each} block iteration.

Notes on why I used the !Array.isArray(obj) check to push array iterables to the else block

The !Array.isArray(obj) check I added is optional, though I did some additional performance testing and found that arrays—specifically—perform better using traditional for loops rather than for..of loops.

However, that is not the case for other iterables (e.g. maps, sets, generators, etc.), which perform significantly better when iterating using a for..of loop. So with that in mind, I am using the !Array.isArray(obj) check to push arrays into the second block, which iterates over them using a traditional for loop instead.

Tests

— see all tests on TS Playground (same link as above)

Here are some tests for that function, each of which works as expected:

  • Array-like example { length: 3 }
    const arrayLikeExample = { length: 3 };
    
    iterateOver(arrayLikeExample, (_: undefined, i: number) => console.log(_, i));
      // -> undefined 0
      // -> undefined 1
      // -> undefined 2
  • Array example ['value1', 'value2', 'value3']
    const arrayExample = ['value1', 'value2', 'value3'];
    
    iterateOver(arrayExample, (val: string, i: number) => console.log(val, i));
      // -> 'value1' 0
      // -> 'value2' 1
      // -> 'value3' 2
  • Set example {'value1', 'value2', 'value3'}
    const setExample = new Set();
    setExample.add('value1');
    setExample.add('value2');
    setExample.add('value3');
    
    iterateOver(setExample, (val: string, i: number) => console.log(val, i));
      // -> 'value1' 0
      // -> 'value2' 1
      // -> 'value3' 2
  • Map example {'key1' => 'value1', 'key2' => 'value2', 'key3' => 'value3'}
    const mapExample = new Map();
    mapExample.set('key1', 'value1');
    mapExample.set('key2', 'value2');
    mapExample.set('key3', 'value3');
    
    iterateOver(mapExample, ([key, val]: [string, string], i: number) => console.log(key, val, i));
      // -> 'key1' 'value1' 0
      // -> 'key2' 'value2' 1
      // -> 'key3' 'value3' 2
  • Generator example 'value1' => 'value2' => 'value3'
    function* generatorExample(): IterableIterator<string> {
      let current = 1;
      while (current <= 3) {
        yield `value${current}`;
        current++;
      }
    }
    
    iterateOver(generatorExample(), (val: string, i: number) => console.log(val, i));
      // -> 'value1' 0
      // -> 'value2' 1
      // -> 'value3' 2

@brandonmcconnell
Copy link
Contributor

And while it arguably reads easier with the isIterable type guard outside iterateOver, you could move that same logic inside iterateOver with relative ease, which I'm guessing may be easier for Svelte since IIRC Svelte doesn't use TS under the hood but JSDoc style typing:

function iterateOver<T>(obj: Iterable<T> | ArrayLike<T>, expose: Function) {
  if (typeof (obj as Iterable<T>)[Symbol.iterator] === 'function' && !Array.isArray(obj)) {
    /* non-array iterables (array iteration using a traditional `for` loop is more performant) */
    let i = 0;
    for (const elem of (obj as Iterable<T>)) {
      expose(elem, i++);
    }
  } else {
    /* arrays and array-likes */
    const indexedObj: ArrayLike<T> = (
      'length' in obj && typeof obj.length === 'number'
        ? (obj as { length: number })
        : Array.from(obj)
    );
    for (let i = 0; i < indexedObj.length ?? 0; i += 1) {
      expose(indexedObj[i], i);
    }
  }
}

— see on TS Playground

Here's a JSDoc version 👇🏼
/**
 * Iterates over an iterable object or array-like object and exposes each element to a callback function.
 * @param {Iterable<T> | ArrayLike<T>} obj - The iterable or array-like object to iterate over.
 * @param {Function} expose - The callback function to call for each element.
 * @returns {void}
 * @template T
 */
function iterateOver(obj, expose) {
  if (typeof obj[Symbol.iterator] === 'function' && !Array.isArray(obj)) {
    /** @type {Iterable<T>} */
    const iterableObj = obj;
    /* non-array iterables (array iteration using a traditional `for` loop is more performant) */
    let i = 0;
    for (const elem of iterableObj) {
      expose(elem, i++);
    }
  } else {
    /** @type {ArrayLike<T>} */
    const indexedObj = (
      'length' in obj && typeof obj.length === 'number'
        ? obj
        : Array.from(obj)
    );
    /* arrays and array-likes */
    for (let i = 0; i < (indexedObj.length ?? 0); i += 1) {
      expose(indexedObj[i], i);
    }
  }
}

@joeally
Copy link
Author

joeally commented Mar 14, 2023

@brandonmcconnell - I'd prefer the user to have to call .entries(). IMO the {#each} should work with any iterable (see: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols).

{#each} should not have a special case for Map and Set.

Yes you're right. I didn't know that Map itself can be iterated over.

@brandonmcconnell
Copy link
Contributor

@joeally Exactly. The Map and Set are not special cases. Every iterable can be iterated over using the same for..of loop style. The only exception is the "array-like" case, which is easy enough to support performantly, and one additional but optional exception is the plain array type.

Array types are iterable in the same way that Map, Set, and generators are, but they actually iterate more performantly if iterated over using a plain for loop, so I use a !Array.isArray(obj) check to push array types into the second block alongside the array-like type.

We can loop over someMap.entries() and someSet.values() alternatively, but generally speaking, it's actually more performant to just loop over maps and sets directly without using entries() and values(), which is also simpler syntax, so win-win. 🎉

@ngtr6788
Copy link
Contributor

ngtr6788 commented Apr 3, 2023

I noticed another issue which possibly is similar to this one, #4289 .

@lucidNTR
Copy link

lucidNTR commented Apr 5, 2023

i cant overstate how exciting this is. this is the one problem that forces me to patch svelte at the moment. basically i have an arraylike js proxy object that unfortunately returns typeof as "function" for unrelated reasons

at the moment the each statement in svelte checks if something is an object which is not true and its also not clear why only objects with a length attribute should be allowed, instead arg?.length seems like reasonable check for array like.

currently the check is

arg && typeof arg === 'object' && 'length' in arg

additionally supporting generic iterables would solve a big pain for me as i often iterate of entries where the number is not known before the iteration ends, this forces me to guess the length and then run into all kinds of issues when when its off

dummdidumm added a commit that referenced this issue May 24, 2023
closes #7425
Uses a new ensure_array_like function to use Array.from in case the variable doesn't have a length property ('length' in 'some string' fails, therefore obj?.length). This ensures other places can stay unmodified. Using for (const x of y) constructs would require large changes across the each block code where it's uncertain that it would work for all cases since the array length is needed in various places.
dummdidumm added a commit that referenced this issue May 26, 2023
closes #7425
Uses a new ensure_array_like function to use Array.from in case the variable doesn't have a length property ('length' in 'some string' fails, therefore obj?.length). This ensures other places can stay unmodified. Using for (const x of y) constructs would require large changes across the each block code where it's uncertain that it would work for all cases since the array length is needed in various places.
@joeally
Copy link
Author

joeally commented Jun 7, 2023

Amazing to see this change in Svelte 4.0. Thanks for all of the hard work @dummdidumm and anyone else who worked on it.

@brandonmcconnell
Copy link
Contributor

brandonmcconnell commented Jun 7, 2023

@dummdidumm Thanks for all your work on this!

Did you see my earlier comments about iterating over iterable values directly (maps, sets, generators, etc.), which greatly improves performance?

If I'm reading the merged PR accurately, it looks like those would all now be converted to arrays using Array.from, which is significantly slower.

@dummdidumm
Copy link
Member

That's correct. We use Array.from because the length of the iterable is used in many places, sometimes before iterating over it, which would not be possible by iterating over the iterable directly. That way it's also way less code changes in the compiler.

@lucidNTR
Copy link

lucidNTR commented Jun 7, 2023

@dummdidumm by that argument you could just say people should wrap iterables with array.from in application layer, that way there would be 0 lines of code change in svelte and we would not break expectations. supporting iterables means by definition allowing to start rendering pages of entries from the iterable without accessing length, if this is too much work at the moment this should just be postponed but this will lead to many issues

@brandonmcconnell
Copy link
Contributor

Also, for many iterables, like maps and sets, you can use size instead of length and get the expected results.

The helper function I outlined above is a single function that accounts for this and manages all cases. With a small tweak, you could limit the performance boost to maps and sets for their size property and convert everything else to an array.

Were there any other downsides to using that?

As @lucidNTR mentioned, users can also do that conversion to arrays explicitly if it's what they want, but in most cases, I explicitly work with maps and sets because they're generally more performant with large datasets. The current implementation flips that upside down and is slower than using an array to begin with.

@dummdidumm
Copy link
Member

The docs state how the iterable is iterated and what expectations there are to it, namely that they are static and synchronous and are iterated over eagerly. You won't get some kind of intermediate rendering support from that (like, you can't iterate five, then another five two seconds later) - this is due to how the rendering system works.

Things like size are not available on all iterables so we can't rely on that.

The code snippet with the iteration over either a regular array or the iterable can't just be applied, it's not that simple. As I said, the length is needed in various places, like when you have keyed each blocks with transitions on the items.

The performance argument is weak because if you're using iterables you're already slower compared to plain old arrays, so if you're performance aware you shouldn't use them anyway.

Yes, in summary this means "just" a small QOL improvement which is that you don't need to do Array.from yourself.

@benmccann
Copy link
Member

Implemented in #8626

@brandonmcconnell
Copy link
Contributor

brandonmcconnell commented Jun 25, 2023

Not sure why I'm not seeing @eabald's comment here anymore, so reposting it here. As it echos the same comments many of us have voiced before:

I use maps exclusively in the stores for record collections because it's much faster to get and update an entry by ID than it would be to retrieve find the index in the array - so I'm also ending up iterating maps with each.

I'd put my vote that Maps deserve dedicated processing while converting all other iterables to arrays.

I think maps and sets both really need this. This is a massive performance deficit for Svelte and can easily be avoided as demonstrated earlier and still support an index, so I'd also argue that the lack of a natural iterable index is a weak argument as well.

@eabald
Copy link

eabald commented Jun 25, 2023

Not sure why I'm not seeing @eabald's comment here anymore, so reposting it here. As it echos the same comments many of us have voiced before:

I use maps exclusively in the stores for record collections because it's much faster to get and update an entry by ID than it would be to retrieve find the index in the array - so I'm also ending up iterating maps with each.
I'd put my vote that Maps deserve dedicated processing while converting all other iterables to arrays.

I think maps and sets both really need this. This is a massive performance deficit for Svelte and can easily be avoided as demonstrated earlier and still support an index, so I'd also argue that the lack of a natural iterable index is a weak argument as well.

@brandonmcconnell I'm pretty sure that I'm not the person You wanted to tag in this comment. My knowledge about Svelte is limited to knowing what it is, so I doubt that I made this rather constructive comment...

@brandonmcconnell
Copy link
Contributor

@eabald My sincere apologies — I got an email notification from someone of the same name and thought it was you.

@eabald
Copy link

eabald commented Jun 25, 2023 via email

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