-
Notifications
You must be signed in to change notification settings - Fork 918
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
Rework repeat directive for better performance. Fixes #242. #501
Conversation
Really great PR, thank you! |
src/directives/repeat.ts
Outdated
} else { | ||
// else part is in the correct position already | ||
currentMarker = itemPart.endNode.nextSibling!; | ||
const result = newResults[index] = template !(item, index); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Arr, if duplicate keys are detected the index
passed here should still reflect the input array items
index (as is it reflects the final result/part index...). It's an error condition either way; worth fixing?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
how would this change if fixed? template
would be called twice with the same item and different index, and the index already is the input array items index, no?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The current behavior for duplicates is that only the first item for a key with duplicates will be included in the results; the rest will be skipped. But to provide the correct index to the template function, we need to increment index
for each item regardless; however since we also store the index in the key map, that one needs to reflect the index taking into account skips -- which is just newKeyToIndexMap.size
Very exciting :)
I had a Even without a common keyValue field, we should check that Promises work, to address #193
Yeah, the current insert/append methods are somewhat ad hoc, and have a restriction that the parts are empty. Now's the time to figure this out and lock it down before 1.0. The PR can be the forcing function for that.
I like that. It fixes #362
I'm working on this again right now. So far I'm mostly on the launcher / record results side of things, and not the specific benchmark implementation, or client-runner (so much at least). |
Hmm, in my mind But I think I see your point; since the same It also points out a mistake I think I made, assuming that the |
src/directives/repeat.ts
Outdated
|
||
// Helper functions for manipulating parts | ||
// TODO(kschaaf): Refactor into Part API? | ||
function createPart( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This does "create" and "update" and "insert" so the name is misleading. What about preparePart
?
We could add insertIntoPart
to the Part API and a lot of the mucking with nodes would go away
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For this and all the other comments about the part helpers, I'm assuming this is throwaway and will be refactored into a better Part API, but want to have an IRL conversation about that...
src/directives/repeat.ts
Outdated
part.commit(); | ||
return part; | ||
} | ||
function movePart( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This would also use insertIntoPart
(on the child) or could be insertBefore
or insertBeforePart
(on the parent to match the DOM api). It'd have to work on a "live" part though.
src/directives/repeat.ts
Outdated
} else { | ||
// else part is in the correct position already | ||
currentMarker = itemPart.endNode.nextSibling!; | ||
const result = newResults[index] = template !(item, index); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
format (npm run format). The space after template is weird and hopefully goes away.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is the result of npm run format
. 🤷♂️
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
you shouldn't need the !
though, template
isn't typed to be nullable.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It was there in the original source. I think it's needed because of the varargs shenanigans. The template
symbol is the 3rd arg and it's optional, and TypeScript isn't smart enough to follow the arguments.length condition for its assignment?
src/directives/repeat.ts
Outdated
} else if (oldStartPart.key == newEndResult.key) { | ||
// Old head matches new tail; update and move to new tail | ||
newParts[newEndIndex] = updatePart(oldStartPart, newEndResult); | ||
movePart(directivePart, oldStartPart, newParts[newEndIndex + 1]); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
oldStartPart === newParts[newEndIndex]
. Since the old part is irrelevant, moving the new one would be easier to understand (similar in next condition as well).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm. My mental model was "update and (possibly) move the old part to the new spot", so the operations are all about making old parts correct in the new order.
src/directives/repeat.ts
Outdated
} else { | ||
// Any mismatches at this point are due to additions or moves; see if | ||
// we have an old part we can reuse and move into place | ||
const oldIndex = oldKeyToIndexMap.get(newEndResult.key); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is it important to go bottom-up from here? If not, changing to top-down is simpler.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, I can flip it back to being top-down. Experimenting with the LIS algorithm made bottom-up easier, but I pulled that out.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
First pass...
src/directives/repeat.ts
Outdated
// worth the code at this point, but could consider if a compelling case | ||
// arises. | ||
while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) { | ||
if (oldStartPart == null) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this check equivalent to if (oldStartIndex > oldEndIndex)
? Or is there a later assignment to oldStartPart
to null it out?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Right, spots in the old array are nulled out as they are moved in the last else clause. Steve had a similar suggestion to add a comment that that's what's going on, since the moves come at the end and a new reader sees them out of order.
Covered most of the feedback in latest update. TODOs:
|
src/directives/repeat.ts
Outdated
let newTail = newResults.length - 1; | ||
|
||
// Overview of O(n) reconciliation algorithm (general approach based on | ||
// ideas found in ivi, vue, snabdom, etc.): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
its snabbdom with two b's
src/directives/repeat.ts
Outdated
// newHead ^ ^ newTail | ||
// | ||
// * If either the new or old pointers move past each other then all we have | ||
// left is additions (if old list exhsusted) or removals (if new list |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
exhausted*
src/directives/repeat.ts
Outdated
// | ||
// * Note that for moves/insertions, a part inserted at the head pointer | ||
// is inserted before the current `oldParts[oldHead]`, and a part inserted | ||
// at the tail pointer is inserved before `newParts[newHead+1]`. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
inserted*
src/directives/repeat.ts
Outdated
// * Note that for moves/insertions, a part inserted at the head pointer | ||
// is inserted before the current `oldParts[oldHead]`, and a part inserted | ||
// at the tail pointer is inserved before `newParts[newHead+1]`. | ||
// The seeming asymetry lies in the fact that new parts are moved into |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
asymmetry* (two m's)
src/directives/repeat.ts
Outdated
// algorithm, as long as the null checks come first (to ensure we're | ||
// always working on valid old parts) and that the final else clause | ||
// comes last (since that's where the expensive moves occur). The | ||
// order of remaining clauses is is just a simple guess at wich cases |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
which*
Would something like this work with a virtual list where only a part of the data is visible on screen (DOM nodes exist) |
@kenchris It all depends on what the strategy of the virtual list is. Typically "keyed" updates mean "always keep this DOM associated with this key" (corollary: never reuse with another key). In the virtual lists we've done in the past, usually the cost of reusing (aka updating the bindings with a different item) for a small number of (visible) items is less than the cost of maintaining a strict key-to-DOM contract which at the limit means throwing out and re-creating the visible DOM, possibly per frame when scrolling quickly. If you eliminate the key-to-DOM contract, the algorithm can be much simpler (simply re-assign visible items to the physical rows in order), and this is usually best for scrolling speed, at the cost of more constraints on the user (e.g. list components must be stateless). But yeah, if you were to do a virtual list where you wanted to maintain strict key-to-DOM contract, an algorithm like this would probably be good. Also, thanks for the spelling fixes; I just installed a comment spellchecker for my vscode! |
Also rename result->value, update comments.
@justinfagnani @sorvell As discussed, I will start on the |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This looks really amazing!
A few things before merging:
- Update the changelog
- Update the repeat docs as necessary. I think mentioning duplicate keys, and the non-keyed behavior would be good.
- Add a test for duplicate keys, if only so we know what we expect to happen now?
newPart.insertAfterNode(startNode); | ||
return newPart; | ||
}; | ||
const updatePart = (part: NodePart, value: unknown) => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
style nit: add a newline between functions
src/directives/repeat.ts
Outdated
|
||
// Stores previous ordered list of parts and map of key to index |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
two spaces between "of" and "parts"
* DOM will always be created for new keys). | ||
* | ||
* If no `keyFn` is provided, this directive will perform similar to mapping | ||
* items to values, and DOM will be reused against potentially different items. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
cc @katejeffreys and @arthurevans for future work to describe this in a user-understandable way :)
src/directives/repeat.ts
Outdated
// pointer range and never visited again. | ||
// | ||
// * Example below: Here the old tail key matches the new head key, so | ||
// the part at the `oldTail` position and move its dom to the new |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
dom => DOM
src/directives/repeat.ts
Outdated
oldTail--; | ||
newHead++; | ||
} else { | ||
if (newKeyToIndexMap! === undefined) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do you need the !
?
src/directives/repeat.ts
Outdated
// we have an old part we can reuse and move into place | ||
const oldIndex = oldKeyToIndexMap!.get(newKeys[newHead]); | ||
const oldPart = oldIndex !== undefined ? oldParts[oldIndex] : null; | ||
if (oldPart == null) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
use ===
let newHead = 0; | ||
let newTail = newValues.length - 1; | ||
|
||
// Overview of O(n) reconciliation algorithm (general approach based on |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this comment is all kinds of awesome
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The docs are awesome. Just a couple nits.
export function repeat<T>( | ||
items: T[], keyFn: KeyFn<T>, template: ItemTemplate<T>): | ||
items: Iterable<T>, keyFn: KeyFn<T>, template: ItemTemplate<T>): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we keep keyFn
optional, it's simpler if it's the last argument.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd rather do that as a separate change, maybe release with any other breaking changes we have at the time. Let's get this repeat() in the hands of current users and make sure it's a drop-in replacement.
src/directives/repeat.ts
Outdated
// we have an old part we can reuse and move into place | ||
const oldIndex = oldKeyToIndexMap!.get(newKeys[newHead]); | ||
const oldPart = oldIndex !== undefined ? oldParts[oldIndex] : null; | ||
if (oldPart == null) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
===
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done.
src/directives/repeat.ts
Outdated
keyMap.forEach(cleanMap); | ||
// Add parts for any remaining new values | ||
while (newHead <= newTail) { | ||
const newPart = createAndInsertPart(containerPart, oldParts[oldHead]!); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you insert before newParts[newTail]
here? Using the oldParts
as anchors is confusing so it'd be nice to only doing it when we have.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The newTail
has already been advanced and so it would actually be newParts[newTail+1]
.
But, I already described the rules for where to insert in both cases here: https://github.com/Polymer/lit-html/pull/501/files#diff-fa22dc099475a72fdfd85c634894b896R202
Even though we're inserting at the "head", the above is only possible since the old parts are out of the picture at this point in the algorithm, so it would be a special difference that would need to be explained, and I think I'd prefer to keep the rule simple as documented: head goes at oldParts[oldHead]
, tail goes at newParts[newTail+1]
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ok, sounds reasonable, but aren't we concerned that oldParts[oldHead]
might be nulled out? That would make the items out of order. It seems like newParts[newTail+1]
is always safe.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Needs changelog but otherwise LTGM
I know you guys mentioned some perf benchmarking was being done. Do you have any number you can show for this PR compared to existing? Great work @kevinpschaaf 👏 |
Here's one based a this branch a few commits ago: That's using js-framework-benchmark |
Wow, that's fantastic! Excellent work everyone! Thanks for the benchmark @CaptainCodeman |
Rework
repeat
directive to use a more efficient algorithm to avoid unnecessary DOM detachment/attachment during common array operations (add, remove, swap, reverse, etc.).A few TODO's in the implementation to discuss:
else
clause (over the subset of old items remaining) didn't show up in benchmarks as significant. This made it straightforward to warn in the case of duplicate keys, which we treat as an error and warn as "undefined behavior", and gives more flexibility to change the algorithm in the future.Fixes #242.