Last active
April 23, 2016 23:36
-
-
Save niieani/2c6423601fbecde7ef62b4087564576d to your computer and use it in GitHub Desktop.
Aurelia: Suboptimal repeat element lifecycle [swap-arrays/reverse + optimized-repeat]
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<template> | |
<require from="./component"></require> | |
<button click.delegate="swapArrays()">Swap arrays</button> | |
<button click.delegate="reverse()">Reverse</button> | |
<br> | |
<component | |
optimized-repeat.for="id of components" | |
text.bind="id"> | |
[${$index}] | |
</component> | |
</template> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import {Component} from './component' | |
export class App { | |
constructor() { | |
this.components = [] | |
for (let i = 1; i <= 10; i++) { | |
this.components.push(i) | |
} | |
this.componentsAlternative = this.components.slice(0).reverse() | |
} | |
swapArrays() { | |
[this.components, this.componentsAlternative] = [this.componentsAlternative, this.components] | |
} | |
reverse() { | |
this.components.reverse() | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import {ArrayRepeatStrategy} from 'aurelia-templating-resources/array-repeat-strategy'; | |
import {createFullOverrideContext, updateOverrideContexts} from 'aurelia-templating-resources/repeat-utilities'; | |
import {mergeSplice} from 'aurelia-binding'; | |
import {updateOverrideContext} from 'aurelia-templating-resources/repeat-utilities'; | |
/** | |
* Returns the index of the element in an array, optionally using a matcher function. | |
*/ | |
export function indexOf(array, item, matcher, startIndex) { | |
if (!matcher) { | |
// native indexOf is more performant than a for loop | |
return array.indexOf(item); | |
} | |
const length = array.length; | |
for (let index = startIndex || 0; index <= length; index++) { | |
const testItem = array[index]; | |
if (matcher(testItem, item)) { | |
return index; | |
} | |
} | |
return -1; | |
} | |
/** | |
* A strategy for repeating a template over an array. | |
*/ | |
export class ArrayOptimizedRepeatStrategy extends ArrayRepeatStrategy { | |
/** | |
* Gets an observer for the specified collection. | |
* @param observerLocator The observer locator instance. | |
* @param items The items to be observed. | |
*/ | |
getCollectionObserver(observerLocator, items) { | |
return observerLocator.getArrayObserver(items); | |
} | |
/** | |
* Handle the repeat's collection instance changing. | |
* @param repeat The repeater instance. | |
* @param items The new array instance. | |
*/ | |
instanceChanged(repeat, items) { | |
const itemsLength = items.length; | |
// if the new instance does not contain any items, | |
// just remove all views and don't do any further processing | |
if (!items || itemsLength === 0) { | |
repeat.removeAllViews(true); | |
return; | |
} | |
const children = repeat.views(); | |
const viewsLength = children.length; | |
// likewise, if we previously didn't have any views, | |
// simply make them and return | |
if (viewsLength === 0) { | |
this._standardProcessInstanceChanged(repeat, items); | |
return; | |
} | |
if (repeat.viewsRequireLifecycle) { | |
const childrenSnapshot = children.slice(0); | |
const itemNameInBindingContext = repeat.local; | |
const matcher = repeat.matcher(); | |
// the cache of the current state (it will be transformed along with the views to keep track of indicies) | |
let itemsPreviouslyInViews = []; | |
const viewsToRemove = []; | |
for (let index = 0; index < viewsLength; index++) { | |
const view = childrenSnapshot[index]; | |
const oldItem = view.bindingContext[itemNameInBindingContext]; | |
if (indexOf(items, oldItem, matcher) === -1) { | |
// remove the item if no longer in the new instance of items | |
viewsToRemove.push(view); | |
} else { | |
// or add the item to the cache list | |
itemsPreviouslyInViews.push(oldItem); | |
} | |
} | |
let updateViews; | |
let removePromise; | |
if (itemsPreviouslyInViews.length > 0) { | |
removePromise = repeat.removeViews(viewsToRemove, true); | |
updateViews = () => { | |
// update views (create new and move existing) | |
for (let index = 0; index < itemsLength; index++) { | |
const item = items[index]; | |
const indexOfView = indexOf(itemsPreviouslyInViews, item, matcher, index); | |
let view; | |
if (indexOfView === -1) { // create views for new items | |
const overrideContext = createFullOverrideContext(repeat, items[index], index, itemsLength); | |
repeat.insertView(index, overrideContext.bindingContext, overrideContext); | |
// reflect the change in our cache list so indicies are valid | |
itemsPreviouslyInViews.splice(index, 0, undefined); | |
} | |
else if (indexOfView === index) { // leave unchanged items | |
view = children[indexOfView]; | |
itemsPreviouslyInViews[indexOfView] = undefined; | |
} | |
else { // move the element to the right place | |
view = children[indexOfView]; | |
repeat.moveView(indexOfView, index); | |
itemsPreviouslyInViews.splice(indexOfView, 1); | |
itemsPreviouslyInViews.splice(index, 0, undefined); | |
} | |
if (view) { | |
updateOverrideContext(view.overrideContext, index, itemsLength); | |
} | |
} | |
// remove extraneous elements in case of duplicates, | |
// also update binding contexts if objects changed using the matcher function | |
this._inPlaceProcessItems(repeat, items); | |
} | |
} else { | |
// if all of the items are different, remove all and add all from scratch | |
removePromise = repeat.removeAllViews(true); | |
updateViews = () => this._standardProcessInstanceChanged(repeat, items); | |
} | |
if (removePromise instanceof Promise) { | |
removePromise.then(updateViews); | |
} else { | |
updateViews(); | |
} | |
} else { | |
// no lifecycle needed, use the fast in-place processing | |
this._inPlaceProcessItems(repeat, items); | |
} | |
} | |
_standardProcessInstanceChanged(repeat, items) { | |
for (let i = 0, ii = items.length; i < ii; i++) { | |
let overrideContext = createFullOverrideContext(repeat, items[i], i, ii); | |
repeat.addView(overrideContext.bindingContext, overrideContext); | |
} | |
} | |
_inPlaceProcessItems(repeat, items) { | |
let itemsLength = items.length; | |
let viewsLength = repeat.viewCount(); | |
// remove unneeded views. | |
while (viewsLength > itemsLength) { | |
viewsLength--; | |
repeat.removeView(viewsLength, true); | |
} | |
// avoid repeated evaluating the property-getter for the "local" property. | |
let local = repeat.local; | |
// re-evaluate bindings on existing views. | |
for (let i = 0; i < viewsLength; i++) { | |
let view = repeat.view(i); | |
let last = i === itemsLength - 1; | |
let middle = i !== 0 && !last; | |
// any changes to the binding context? | |
if (view.bindingContext[local] === items[i] | |
&& view.overrideContext.$middle === middle | |
&& view.overrideContext.$last === last) { | |
// no changes. continue... | |
continue; | |
} | |
// update the binding context and refresh the bindings. | |
view.bindingContext[local] = items[i]; | |
view.overrideContext.$middle = middle; | |
view.overrideContext.$last = last; | |
repeat.updateBindings(view); | |
} | |
// add new views | |
for (let i = viewsLength; i < itemsLength; i++) { | |
let overrideContext = createFullOverrideContext(repeat, items[i], i, itemsLength); | |
repeat.addView(overrideContext.bindingContext, overrideContext); | |
} | |
} | |
/** | |
* Handle the repeat's collection instance mutating. | |
* @param repeat The repeat instance. | |
* @param array The modified array. | |
* @param splices Records of array changes. | |
*/ | |
instanceMutated(repeat, array, splices) { | |
if (repeat.viewsRequireLifecycle) { | |
this._standardProcessInstanceMutated(repeat, array, splices); | |
return; | |
} | |
this._inPlaceProcessItems(repeat, array); | |
} | |
_standardProcessInstanceMutated(repeat, array, splices) { | |
if (repeat.__queuedSplices) { | |
for (let i = 0, ii = splices.length; i < ii; ++i) { | |
let {index, removed, addedCount} = splices[i]; | |
mergeSplice(repeat.__queuedSplices, index, removed, addedCount); | |
} | |
// Array.prototype.slice is used here to clone the array | |
repeat.__array = array.slice(0); | |
return; | |
} | |
// Array.prototype.slice is used here to clone the array | |
let maybePromise = this._runSplices(repeat, array.slice(0), splices); | |
if (maybePromise instanceof Promise) { | |
let queuedSplices = repeat.__queuedSplices = []; | |
let runQueuedSplices = () => { | |
if (!queuedSplices.length) { | |
repeat.__queuedSplices = undefined; | |
repeat.__array = undefined; | |
return; | |
} | |
let nextPromise = this._runSplices(repeat, repeat.__array, queuedSplices) || Promise.resolve(); | |
queuedSplices = repeat.__queuedSplices = []; | |
nextPromise.then(runQueuedSplices); | |
}; | |
maybePromise.then(runQueuedSplices); | |
} | |
} | |
/** | |
* Run a normalised set of splices against the viewSlot children. | |
* @param repeat The repeat instance. | |
* @param array The modified array. | |
* @param splices Records of array changes. | |
* @return {Promise|undefined} A promise if animations have to be run. | |
* @pre The splices must be normalised so as: | |
* * Any item added may not be later removed. | |
* * Removals are ordered by asending index | |
*/ | |
_runSplices(repeat, array, splices) { | |
let removeDelta = 0; | |
let rmPromises = []; | |
for (let i = 0, ii = splices.length; i < ii; ++i) { | |
let splice = splices[i]; | |
let removed = splice.removed; | |
for (let j = 0, jj = removed.length; j < jj; ++j) { | |
// the rmPromises.length correction works due to the ordered removal precondition | |
let viewOrPromise = repeat.removeView(splice.index + removeDelta + rmPromises.length, true); | |
if (viewOrPromise instanceof Promise) { | |
rmPromises.push(viewOrPromise); | |
} | |
} | |
removeDelta -= splice.addedCount; | |
} | |
if (rmPromises.length > 0) { | |
return Promise.all(rmPromises).then(() => { | |
let spliceIndexLow = this._handleAddedSplices(repeat, array, splices); | |
updateOverrideContexts(repeat.views(), spliceIndexLow); | |
}); | |
} | |
let spliceIndexLow = this._handleAddedSplices(repeat, array, splices); | |
updateOverrideContexts(repeat.views(), spliceIndexLow); | |
} | |
_handleAddedSplices(repeat, array, splices) { | |
let spliceIndex; | |
let spliceIndexLow; | |
let arrayLength = array.length; | |
for (let i = 0, ii = splices.length; i < ii; ++i) { | |
let splice = splices[i]; | |
let addIndex = spliceIndex = splice.index; | |
let end = splice.index + splice.addedCount; | |
if (typeof spliceIndexLow === 'undefined' || spliceIndexLow === null || spliceIndexLow > splice.index) { | |
spliceIndexLow = spliceIndex; | |
} | |
for (; addIndex < end; ++addIndex) { | |
let overrideContext = createFullOverrideContext(repeat, array[addIndex], addIndex, arrayLength); | |
repeat.insertView(addIndex, overrideContext.bindingContext, overrideContext); | |
} | |
} | |
return spliceIndexLow; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import {inlineView, bindable} from 'aurelia-framework' | |
@inlineView('<template><content></content>${text}<br></template>') | |
export class Component { | |
@bindable text | |
attached() { | |
console.log('attached', this.text) | |
} | |
bind() { | |
console.log('bind', this.text) | |
} | |
unbind() { | |
console.log('unbind', this.text) | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!doctype html> | |
<html> | |
<head> | |
<title>Aurelia</title> | |
<meta name="viewport" content="width=device-width, initial-scale=1"> | |
</head> | |
<body> | |
<h1>Loading...</h1> | |
<script src="https://jdanyow.github.io/rjs-bundle/node_modules/requirejs/require.js"></script> | |
<script src="https://jdanyow.github.io/rjs-bundle/config.js"></script> | |
<script src="https://jdanyow.github.io/rjs-bundle/bundles/aurelia.js"></script> | |
<script src="https://jdanyow.github.io/rjs-bundle/bundles/babel.js"></script> | |
<script> | |
require(['./main']); | |
</script> | |
</body> | |
</html> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import {bootstrap} from 'aurelia-bootstrapper'; | |
bootstrap((aurelia: Aurelia): void => { | |
aurelia.use | |
.standardConfiguration() | |
.developmentLogging(); | |
aurelia.use.plugin('optimized-repeat-index'); | |
aurelia.start().then(() => aurelia.setRoot('app', document.body)); | |
}); | |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import {OptimizedRepeat} from './optimized-repeat'; | |
import {ViewSlot} from 'aurelia-templating'; | |
export function configure(config) { | |
config.globalResources( | |
'optimized-repeat' | |
); | |
ViewSlot.prototype.move = function move(sourceIndex, targetIndex) { | |
if (sourceIndex === targetIndex) return | |
const children = this.children; | |
const view = children[sourceIndex] | |
view.removeNodes(); | |
view.insertNodesBefore(children[targetIndex].firstChild); | |
// remove | |
children.splice(sourceIndex, 1); | |
// readd | |
children.splice(targetIndex, 0, view); | |
}; | |
ViewSlot.prototype.removeMany = function removeMany(viewsToRemove: View[], returnToCache?: boolean, skipAnimation?: boolean): void | Promise<View> { | |
const children = this.children; | |
let ii = viewsToRemove.length; | |
let i; | |
let rmPromises = []; | |
viewsToRemove.forEach(child => { | |
if (skipAnimation) { | |
child.removeNodes(); | |
return; | |
} | |
let animatableElement = getAnimatableElement(child); | |
if (animatableElement !== null) { | |
rmPromises.push(this.animator.leave(animatableElement).then(() => child.removeNodes())); | |
} else { | |
child.removeNodes(); | |
} | |
}); | |
let removeAction = () => { | |
if (this.isAttached) { | |
for (i = 0; i < ii; ++i) { | |
viewsToRemove[i].detached(); | |
} | |
} | |
if (returnToCache) { | |
for (i = 0; i < ii; ++i) { | |
viewsToRemove[i].returnToCache(); | |
} | |
} | |
for (i = 0; i < ii; ++i) { | |
const index = children.indexOf(viewsToRemove[i]); | |
if (index >= 0) { | |
children.splice(index, 1); | |
} | |
} | |
}; | |
if (rmPromises.length > 0) { | |
return Promise.all(rmPromises).then(() => removeAction()); | |
} | |
removeAction(); | |
} | |
} | |
export { | |
OptimizedRepeat | |
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import {RepeatStrategyLocator} from 'aurelia-templating-resources/repeat-strategy-locator'; | |
import {ArrayOptimizedRepeatStrategy} from './array-optimized-repeat-strategy'; | |
export class OptimizedRepeatStrategyLocator extends RepeatStrategyLocator { | |
constructor() { | |
super(); | |
this.matchers = []; | |
this.strategies = []; | |
this.addStrategy(items => items instanceof Array, new ArrayOptimizedRepeatStrategy()); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/*eslint no-loop-func:0, no-unused-vars:0*/ | |
import {inject} from 'aurelia-dependency-injection'; | |
import {ObserverLocator} from 'aurelia-binding'; | |
import { | |
BoundViewFactory, | |
TargetInstruction, | |
ViewSlot, | |
ViewResources, | |
customAttribute, | |
bindable, | |
templateController | |
} from 'aurelia-templating'; | |
import {OptimizedRepeatStrategyLocator} from './optimized-repeat-strategy-locator'; | |
import { | |
getItemsSourceExpression, | |
unwrapExpression, | |
isOneTime, | |
updateOneTimeBinding | |
} from 'aurelia-templating-resources/repeat-utilities'; //'./repeat-utilities'; | |
import {viewsRequireLifecycle} from 'aurelia-templating-resources/analyze-view-factory' // './analyze-view-factory'; | |
import {AbstractRepeater} from 'aurelia-templating-resources/abstract-repeater'; //'./abstract-repeater'; | |
/** | |
* Binding to iterate over iterable objects (Array, Map and Number) to genereate a template for each iteration. | |
*/ | |
@customAttribute('optimized-repeat') | |
@templateController | |
@inject(BoundViewFactory, TargetInstruction, ViewSlot, ViewResources, ObserverLocator, OptimizedRepeatStrategyLocator) | |
export class OptimizedRepeat extends AbstractRepeater { | |
/** | |
* List of items to bind the repeater to. | |
* | |
* @property items | |
*/ | |
@bindable items | |
/** | |
* Local variable which gets assigned on each iteration. | |
* | |
* @property local | |
*/ | |
@bindable local | |
/** | |
* Key when iterating over Maps. | |
* | |
* @property key | |
*/ | |
@bindable key | |
/** | |
* Value when iterating over Maps. | |
* | |
* @property value | |
*/ | |
@bindable value | |
/** | |
* Creates an instance of Repeat. | |
* @param viewFactory The factory generating the view | |
* @param instruction The instructions for how the element should be enhanced. | |
* @param viewResources Collection of resources used to compile the the views. | |
* @param viewSlot The slot the view is injected in to. | |
* @param observerLocator The observer locator instance. | |
* @param collectionStrategyLocator The strategy locator to locate best strategy to iterate the collection. | |
*/ | |
constructor(viewFactory, instruction, viewSlot, viewResources, observerLocator, strategyLocator) { | |
super({ | |
local: 'item', | |
viewsRequireLifecycle: viewsRequireLifecycle(viewFactory) | |
}); | |
this.viewFactory = viewFactory; | |
this.instruction = instruction; | |
this.viewSlot = viewSlot; | |
this.lookupFunctions = viewResources.lookupFunctions; | |
this.observerLocator = observerLocator; | |
this.key = 'key'; | |
this.value = 'value'; | |
this.strategyLocator = strategyLocator; | |
this.ignoreMutation = false; | |
this.sourceExpression = getItemsSourceExpression(this.instruction, 'optimized-repeat.for'); | |
this.isOneTime = isOneTime(this.sourceExpression); | |
this.viewsRequireLifecycle = viewsRequireLifecycle(viewFactory); | |
} | |
call(context, changes) { | |
this[context](this.items, changes); | |
} | |
/** | |
* Binds the repeat to the binding context and override context. | |
* @param bindingContext The binding context. | |
* @param overrideContext An override context for binding. | |
*/ | |
bind(bindingContext, overrideContext) { | |
this.scope = { bindingContext, overrideContext }; | |
this.itemsChanged(); | |
} | |
/** | |
* Unbinds the repeat | |
*/ | |
unbind() { | |
this.scope = null; | |
this.items = null; | |
this.viewSlot.removeAll(true); | |
this._unsubscribeCollection(); | |
} | |
_unsubscribeCollection() { | |
if (this.collectionObserver) { | |
this.collectionObserver.unsubscribe(this.callContext, this); | |
this.collectionObserver = null; | |
this.callContext = null; | |
} | |
} | |
/** | |
* Invoked everytime the item property changes. | |
*/ | |
itemsChanged() { | |
this._unsubscribeCollection(); | |
// still bound? | |
if (!this.scope) { | |
return; | |
} | |
let items = this.items; | |
this.strategy = this.strategyLocator.getStrategy(items); | |
if (!this.strategy) { | |
throw new Error(`Value for '${this.sourceExpression}' is non-repeatable`); | |
} | |
if (!this.isOneTime && !this._observeInnerCollection()) { | |
this._observeCollection(); | |
} | |
this.strategy.instanceChanged(this, items); | |
} | |
_getInnerCollection() { | |
let expression = unwrapExpression(this.sourceExpression); | |
if (!expression) { | |
return null; | |
} | |
return expression.evaluate(this.scope, null); | |
} | |
/** | |
* Invoked when the underlying collection changes. | |
*/ | |
handleCollectionMutated(collection, changes) { | |
if (!this.collectionObserver) { | |
return; | |
} | |
this.strategy.instanceMutated(this, collection, changes); | |
} | |
/** | |
* Invoked when the underlying inner collection changes. | |
*/ | |
handleInnerCollectionMutated(collection, changes) { | |
if (!this.collectionObserver) { | |
return; | |
} | |
// guard against source expressions that have observable side-effects that could | |
// cause an infinite loop- eg a value converter that mutates the source array. | |
if (this.ignoreMutation) { | |
return; | |
} | |
this.ignoreMutation = true; | |
let newItems = this.sourceExpression.evaluate(this.scope, this.lookupFunctions); | |
this.observerLocator.taskQueue.queueMicroTask(() => this.ignoreMutation = false); | |
// call itemsChanged... | |
if (newItems === this.items) { | |
// call itemsChanged directly. | |
this.itemsChanged(); | |
} else { | |
// call itemsChanged indirectly by assigning the new collection value to | |
// the items property, which will trigger the self-subscriber to call itemsChanged. | |
this.items = newItems; | |
} | |
} | |
_observeInnerCollection() { | |
let items = this._getInnerCollection(); | |
let strategy = this.strategyLocator.getStrategy(items); | |
if (!strategy) { | |
return false; | |
} | |
this.collectionObserver = strategy.getCollectionObserver(this.observerLocator, items); | |
if (!this.collectionObserver) { | |
return false; | |
} | |
this.callContext = 'handleInnerCollectionMutated'; | |
this.collectionObserver.subscribe(this.callContext, this); | |
return true; | |
} | |
_observeCollection() { | |
let items = this.items; | |
this.collectionObserver = this.strategy.getCollectionObserver(this.observerLocator, items); | |
if (this.collectionObserver) { | |
this.callContext = 'handleCollectionMutated'; | |
this.collectionObserver.subscribe(this.callContext, this); | |
} | |
} | |
// @override AbstractRepeater | |
viewCount() { return this.viewSlot.children.length; } | |
views() { return this.viewSlot.children; } | |
view(index) { return this.viewSlot.children[index]; } | |
matcher() { return (this.scope.bindingContext ? this.scope.bindingContext.matcher : null) || null; } | |
addView(bindingContext, overrideContext) { | |
let view = this.viewFactory.create(); | |
view.bind(bindingContext, overrideContext); | |
this.viewSlot.add(view); | |
} | |
insertView(index, bindingContext, overrideContext) { | |
let view = this.viewFactory.create(); | |
view.bind(bindingContext, overrideContext); | |
this.viewSlot.insert(index, view); | |
} | |
moveView(sourceIndex, targetIndex) { | |
this.viewSlot.move(sourceIndex, targetIndex); | |
} | |
removeAllViews(returnToCache, skipAnimation) { | |
return this.viewSlot.removeAll(returnToCache, skipAnimation); | |
} | |
removeViews(viewsToRemove, returnToCache, skipAnimation) { | |
return this.viewSlot.removeMany(viewsToRemove, returnToCache, skipAnimation); | |
} | |
removeView(index, returnToCache, skipAnimation) { | |
return this.viewSlot.removeAt(index, returnToCache, skipAnimation); | |
} | |
updateBindings(view: View) { | |
let j = view.bindings.length; | |
while (j--) { | |
updateOneTimeBinding(view.bindings[j]); | |
} | |
j = view.controllers.length; | |
while (j--) { | |
let k = view.controllers[j].boundProperties.length; | |
while (k--) { | |
let binding = view.controllers[j].boundProperties[k].binding; | |
updateOneTimeBinding(binding); | |
} | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment