Repository: aurelia/templating-resources (ts)

Issue:
matcher.bind does not work inside of repeat
<!--
BUGS: Please use this template.

SUPPORT REQUESTS/QUESTIONS: If you have a support request or question please
submit them to StackOverflow using the tags aurelia and aurelia-framework
http://stackoverflow.com/questions/tagged/aurelia
or the Aurelia Gitter https://gitter.im/aurelia/discuss

Blue Spire offers paid support agreements. Further information regarding paid support
may be obtained by emailing support@bluespire.com

Future support requests submitted here will be closed.

The HTML comments below are for your reference, and are not displayed
when your issue is submitted, feel free to leave them.

Choose one of the two headings, delete the other.
-->
**I'm submitting a bug report**

* **Library Version:**
1.10.0


**Please tell us about your environment:**
* **Operating System:**
Windows 10

* **Node Version:**
10..0
<!--
Minimum supported Node version is latest Node 4.x LTS
run `node -v`
-->

* **NPM Version:**
6.1.0
<!--
Minimum supported NPM version is 3.x
run `npm -v`
-->

* **JSPM OR Webpack AND Version**
webpack 4.31.0
<!--
If using JSPM
run `jspm -v`
If using Webpack
run `webpack --help | grep webpack`
-->

* **Browser:**
Chrome

* **Language:**
TypeScript


**Current behavior:**
matcher.bind does not work inside of repeat.
It works with no problem in 1.9.1.

**Expected/desired behavior:**
<!--
If the current behavior is a bug, please provide the steps to reproduce and, if possible, a minimal demo of the
problem along with a runnable gist, if possible.
To create a runnable gist, go to https://gist.run/?id=7542e061bc940cde506b&sha=6821c521a6c7bae6f59a36fb8628ecd1032b2d10
Then click "Fork to Public Gist".
Create your gist, then finally click "Update Gist." Include a link to the gist.run below.
-->
DEMO: https://codesandbox.io/s/objects-uc90f?fontsize=14&module=%2Fsrc%2Fapp.ts
Forked from the demo at https://aurelia.io/docs/binding/radios.
* **What is the expected behavior?**
The matcher works inside of repeat.


Pull request:
fix(repeat): properly extract matcher binding
At the moment, it simply walks through the instruction list to get the right matcher binding, which will break in scenario where folks have bindings with matcher in it, for example: a list of input box:
```html
<label repeat.for="item of items">
  <input matcher.bind="itemMatcher" />
</label>
```
Current code will mis-identify `matcher.bind="itemMatcher"` as repeat matcher.

Fixes #378 

need to investigate #295

---
Explanation:

Every repeat by default looks for a `matcher` binding by walking through view factory instructions and looking for a binding expression targeting a property with name `matcher`. What our current implementation does not do is distinguishing between a `matcher` binding expression on the repeated element, and any element inside it. So for the following template:

```html
<div repeat.for="product of products"
  matcher.bind="product_matcher_by_id">
  <!-- a custom element with inner structure like this -->
  <label>
    <input type="radio"
      matcher.bind="tag_matcher_by_id"
      model.bind="tag"
      checked.bind="product.selectedTag">
      ${tag.name}
  </label>
</div>
```

it works find, but it fails on the following template:

```html
<div repeat.for="product of products">
  <label>
    <input type="radio"
      matcher.bind="tag_matcher_by_id"
      model.bind="tag"
      checked.bind="product.selectedTag">
      ${tag.name}
  </label>
</div>
```
as it gets the `matcher` binding on the `<input/>` element.


Patch:
diff --git a/src/array-repeat-strategy.ts b/src/array-repeat-strategy.ts
index f190ee6..b7df4b0 100644
--- a/src/array-repeat-strategy.ts
+++ b/src/array-repeat-strategy.ts
@@ -1,5 +1,6 @@
 import {createFullOverrideContext, updateOverrideContexts, updateOverrideContext, indexOf} from './repeat-utilities';
 import {mergeSplice} from 'aurelia-binding';
+import { Repeat } from './repeat';
 
 /**
  * A strategy for repeating a template over an array.
@@ -20,29 +21,30 @@ export class ArrayRepeatStrategy {
    * @param items The new array instance.
    */
   instanceChanged(repeat, items) {
+    const $repeat = repeat as Repeat;
     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, !repeat.viewsRequireLifecycle);
+      $repeat.removeAllViews(true, !$repeat.viewsRequireLifecycle);
       return;
     }
 
-    const children = repeat.views();
+    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);
+      this._standardProcessInstanceChanged($repeat, items);
       return;
     }
 
-    if (repeat.viewsRequireLifecycle) {
+    if ($repeat.viewsRequireLifecycle) {
       const childrenSnapshot = children.slice(0);
-      const itemNameInBindingContext = repeat.local;
-      const matcher = repeat.matcher();
+      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 = [];
@@ -65,7 +67,7 @@ export class ArrayRepeatStrategy {
       let removePromise;
 
       if (itemsPreviouslyInViews.length > 0) {
-        removePromise = repeat.removeViews(viewsToRemove, true, !repeat.viewsRequireLifecycle);
+        removePromise = $repeat.removeViews(viewsToRemove, true, !$repeat.viewsRequireLifecycle);
         updateViews = () => {
           // update views (create new and move existing)
           for (let index = 0; index < itemsLength; index++) {
@@ -74,8 +76,8 @@ export class ArrayRepeatStrategy {
             let view;
 
             if (indexOfView === -1) { // create views for new items
-              const overrideContext = createFullOverrideContext(repeat, items[index], index, itemsLength);
-              repeat.insertView(index, overrideContext.bindingContext, overrideContext);
+              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
@@ -83,7 +85,7 @@ export class ArrayRepeatStrategy {
               itemsPreviouslyInViews[indexOfView] = undefined;
             } else { // move the element to the right place
               view = children[indexOfView];
-              repeat.moveView(indexOfView, index);
+              $repeat.moveView(indexOfView, index);
               itemsPreviouslyInViews.splice(indexOfView, 1);
               itemsPreviouslyInViews.splice(index, 0, undefined);
             }
@@ -95,12 +97,12 @@ export class ArrayRepeatStrategy {
 
           // remove extraneous elements in case of duplicates,
           // also update binding contexts if objects changed using the matcher function
-          this._inPlaceProcessItems(repeat, items);
+          this._inPlaceProcessItems($repeat, items);
         };
       } else {
         // if all of the items are different, remove all and add all from scratch
-        removePromise = repeat.removeAllViews(true, !repeat.viewsRequireLifecycle);
-        updateViews = () => this._standardProcessInstanceChanged(repeat, items);
+        removePromise = $repeat.removeAllViews(true, !$repeat.viewsRequireLifecycle);
+        updateViews = () => this._standardProcessInstanceChanged($repeat, items);
       }
 
       if (removePromise instanceof Promise) {
@@ -110,7 +112,7 @@ export class ArrayRepeatStrategy {
       }
     } else {
       // no lifecycle needed, use the fast in-place processing
-      this._inPlaceProcessItems(repeat, items);
+      this._inPlaceProcessItems($repeat, items);
     }
   }
 
diff --git a/src/aurelia-templating-resources.ts b/src/aurelia-templating-resources.ts
index ac11261..4527d19 100644
--- a/src/aurelia-templating-resources.ts
+++ b/src/aurelia-templating-resources.ts
@@ -37,6 +37,7 @@ import {
 } from './repeat-utilities';
 import {viewsRequireLifecycle} from './analyze-view-factory';
 import {injectAureliaHideStyleAtHead} from './aurelia-hide-style';
+import './interfaces';
 
 function configure(config: any) {
   injectAureliaHideStyleAtHead();
diff --git a/src/interfaces.ts b/src/interfaces.ts
new file mode 100644
index 0000000..23377ab
--- /dev/null
+++ b/src/interfaces.ts
@@ -0,0 +1,17 @@
+import { BindingExpression } from 'aurelia-binding';
+import { TargetInstruction } from 'aurelia-templating';
+
+/**@internal */
+declare module 'aurelia-templating' {
+  interface ViewFactory {
+    instructions: Record<string, TargetInstruction>;
+    template: DocumentFragment;
+  }
+}
+
+/**@internal */
+declare module 'aurelia-binding' {
+  interface BindingExpression {
+    targetProperty: string;
+  }
+}
diff --git a/src/repeat.ts b/src/repeat.ts
index bae8992..b8bf089 100644
--- a/src/repeat.ts
+++ b/src/repeat.ts
@@ -1,6 +1,6 @@
 /*eslint no-loop-func:0, no-unused-vars:0*/
 import {inject} from 'aurelia-dependency-injection';
-import {ObserverLocator} from 'aurelia-binding';
+import {ObserverLocator, BindingExpression} from 'aurelia-binding';
 import {
   BoundViewFactory,
   TargetInstruction,
@@ -9,7 +9,8 @@ import {
   customAttribute,
   bindable,
   templateController,
-  View
+  View,
+  ViewFactory
 } from 'aurelia-templating';
 import {RepeatStrategyLocator} from './repeat-strategy-locator';
 import {
@@ -28,6 +29,16 @@ import {AbstractRepeater} from './abstract-repeater';
 @templateController
 @inject(BoundViewFactory, TargetInstruction, ViewSlot, ViewResources, ObserverLocator, RepeatStrategyLocator)
 export class Repeat extends AbstractRepeater {
+
+  /**
+   * Setting this to `true` to enable legacy behavior, where a repeat would take first `matcher` binding
+   * any where inside its view if there's no `matcher` binding on the repeated element itself.
+   *
+   * Default value is true to avoid breaking change
+   * @default true
+   */
+  static useInnerMatcher = true;
+
   /**
    * List of items to bind the repeater to.
    *
@@ -259,24 +270,34 @@ export class Repeat extends AbstractRepeater {
   }
 
   /**
+   * Capture and remove matcher binding is a way to cache matcher binding + reduce redundant work
+   * caused by multiple unnecessary matcher bindings
    * @internal
    */
   _captureAndRemoveMatcherBinding() {
-    if (this.viewFactory.viewFactory) {
-      const instructions = this.viewFactory.viewFactory.instructions;
-      const instructionIds = Object.keys(instructions);
-      for (let i = 0; i < instructionIds.length; i++) {
-        const expressions = instructions[instructionIds[i]].expressions;
-        if (expressions) {
-          for (let ii = 0; ii < expressions.length; ii++) {
-            if (expressions[ii].targetProperty === 'matcher') {
-              const matcherBinding = expressions[ii];
-              expressions.splice(ii, 1);
-              return matcherBinding;
-            }
-          }
-        }
+    const viewFactory: ViewFactory = this.viewFactory.viewFactory;
+    if (viewFactory) {
+      const template = viewFactory.template;
+      const instructions = viewFactory.instructions;
+      // legacy behavior enabled when Repeat.useInnerMathcer === true
+      if (Repeat.useInnerMatcher) {
+        return extractMatcherBindingExpression(instructions);
+      }
+      // if the template has more than 1 immediate child element
+      // it's a repeat put on a <template/> element
+      // not valid for matcher binding
+      if (template.children.length > 1) {
+        return undefined;
       }
+      // if the root element does not have any instruction
+      // it means there's no matcher binding
+      // no need to do any further work
+      const repeatedElement = template.firstElementChild;
+      if (!repeatedElement.hasAttribute('au-target-id')) {
+        return undefined;
+      }
+      const repeatedElementTargetId = repeatedElement.getAttribute('au-target-id');
+      return extractMatcherBindingExpression(instructions, repeatedElementTargetId);
     }
 
     return undefined;
@@ -286,7 +307,12 @@ export class Repeat extends AbstractRepeater {
   viewCount() { return this.viewSlot.children.length; }
   views() { return this.viewSlot.children; }
   view(index) { return this.viewSlot.children[index]; }
-  matcher() { return this.matcherBinding ? this.matcherBinding.sourceExpression.evaluate(this.scope, this.matcherBinding.lookupFunctions) : null; }
+  matcher() {
+    const matcherBinding = this.matcherBinding;
+    return matcherBinding
+      ? matcherBinding.sourceExpression.evaluate(this.scope, matcherBinding.lookupFunctions)
+      : null;
+  }
 
   addView(bindingContext, overrideContext) {
     let view = this.viewFactory.create();
@@ -332,3 +358,28 @@ export class Repeat extends AbstractRepeater {
     }
   }
 }
+
+/**
+ * Iterate a record of TargetInstruction and their expressions to find first binding expression that targets property named "matcher"
+ */
+const extractMatcherBindingExpression = (instructions: Record<string, TargetInstruction>, targetedElementId?: string): BindingExpression | undefined => {
+  const instructionIds = Object.keys(instructions);
+  for (let i = 0; i < instructionIds.length; i++) {
+    const instructionId = instructionIds[i];
+    // matcher binding can only works when root element is not a <template/>
+    // checking first el child
+    if (targetedElementId !== undefined && instructionId !== targetedElementId) {
+      continue;
+    }
+    const expressions = instructions[instructionId].expressions as BindingExpression[];
+    if (expressions) {
+      for (let ii = 0; ii < expressions.length; ii++) {
+        if (expressions[ii].targetProperty === 'matcher') {
+          const matcherBindingExpression = expressions[ii];
+          expressions.splice(ii, 1);
+          return matcherBindingExpression;
+        }
+      }
+    }
+  }
+};


Test patch:
diff --git a/test/map-repeat-strategy.spec.ts b/test/map-repeat-strategy.spec.ts
index d595e36..3f6deb2 100644
--- a/test/map-repeat-strategy.spec.ts
+++ b/test/map-repeat-strategy.spec.ts
@@ -45,57 +45,64 @@ describe('MapRepeatStrategy', () => {
       view3.bindingContext = { item: ['john', 'doe'] };
       view3.overrideContext = {};
       viewSlot.children = [view1, view2, view3];
-      viewFactorySpy = spyOn(viewFactory, 'create').and.callFake(() => {});
+      viewFactorySpy = spyOn(viewFactory, 'create').and.callFake(() => {/**/});
     });
-  
+
     it('should correctly handle adding item (i.e Map.prototype.set())', () => {
-      repeat = new Repeat(new ViewFactoryMock(), instructionMock, viewSlot, viewResourcesMock, new ObserverLocator());
+      repeat = new Repeat(
+        new ViewFactoryMock(),
+        instructionMock,
+        viewSlot,
+        viewResourcesMock,
+        new ObserverLocator(),
+        null
+      );
       let bindingContext = {};
       repeat.scope = { bindingContext, overrideContext: createOverrideContext(bindingContext) };
       records = [
-        {"type": "add", "object": {}, "key": 'norf'}
-      ]
+        {'type': 'add', 'object': {}, 'key': 'norf'}
+      ];
       items = new Map([['foo', 'bar'], ['qux', 'qax'], ['john', 'doe'], ['norf', 'narf']]);
-      spyOn(viewSlot, 'removeAt').and.callFake(() => { return new ViewMock();});
+      spyOn(viewSlot, 'removeAt').and.callFake(() => { return new ViewMock(); });
       strategy.instanceMutated(repeat, items, records);
-    
+
       expect(viewSlot.children.length).toBe(4);
       expect(viewSlot.children[3].bindingContext.key).toBe('norf');
       expect(viewSlot.children[3].overrideContext.$index).toBe(3);
       expect(viewSlot.children[3].overrideContext.$first).toBe(false);
       expect(viewSlot.children[3].overrideContext.$last).toBe(true);
     });
-  
+
     it('should correctly handle clear items (i.e Map.prototype.clear())', () => {
       let view4 = new ViewMock();
       view4.bindingContext = { item: ['norf', 'narf'] };
       view4.overrideContext = {};
       let viewSlotMock = new ViewSlotMock();
       viewSlotMock.children = [view1, view2, view3, view4];
-      repeat = new Repeat(new ViewFactoryMock(), instructionMock, viewSlotMock, viewResourcesMock, new ObserverLocator());
+      repeat = new Repeat(new ViewFactoryMock(), instructionMock, viewSlotMock, viewResourcesMock, new ObserverLocator(), null);
       let bindingContext = {};
       repeat.scope = { bindingContext, overrideContext: createOverrideContext(bindingContext) };
       records = [
-        {"type": "clear", "object": {}}
-      ]
+        {'type': 'clear', 'object': {}}
+      ];
       items = new Map();
       strategy.instanceMutated(repeat, items, records);
-    
+
       expect(viewSlotMock.children.length).toBe(0);
     });
-  
+
     it('should correctly handle adding items after clear (issue 287)', () => {
       viewSlot.children = [view1, view2, view3];
-      repeat = new Repeat(new ViewFactoryMock(), instructionMock, viewSlot, viewResourcesMock, new ObserverLocator());
+      repeat = new Repeat(new ViewFactoryMock(), instructionMock, viewSlot, viewResourcesMock, new ObserverLocator(), null);
       let bindingContext = {};
       repeat.scope = { bindingContext, overrideContext: createOverrideContext(bindingContext) };
       records = [
-        {"type": "clear", "object": {}},
-        {"type": "add", "object": {}, "key": 'foo'},
-        {"type": "add", "object": {}, "key": 'qux'},
-        {"type": "add", "object": {}, "key": 'john'},
-        {"type": "add", "object": {}, "key": 'norf'}
-      ]
+        {'type': 'clear', 'object': {}},
+        {'type': 'add', 'object': {}, 'key': 'foo'},
+        {'type': 'add', 'object': {}, 'key': 'qux'},
+        {'type': 'add', 'object': {}, 'key': 'john'},
+        {'type': 'add', 'object': {}, 'key': 'norf'}
+      ];
       items = new Map([['foo', 'bar'], ['qux', 'qax'], ['john', 'doe'], ['norf', 'narf']]);
       strategy.instanceMutated(repeat, items, records);
 
diff --git a/test/mocks.ts b/test/mocks.ts
index 0abf50b..8e9b015 100644
--- a/test/mocks.ts
+++ b/test/mocks.ts
@@ -1,3 +1,4 @@
+// tslint:disable:no-empty
 import { Repeat } from '../src/repeat';
 
 export class ViewSlotMock {
@@ -5,21 +6,21 @@ export class ViewSlotMock {
   constructor() {
     this.children = [];
   }
-  removeAll(){
+  removeAll() {
     this.children.splice(0, this.children.length);
   }
-  add(view){
+  add(view) {
     this.children.push(view);
   }
-  insert(index, view){
-    if(index < 0) {
-      throw "negative index";
+  insert(index, view) {
+    if (index < 0) {
+      throw 'negative index';
     }
     this.children.splice(index, 0, view);
   }
-  removeAt(index){
-    if(index < 0) {
-      throw "negative index";
+  removeAt(index) {
+    if (index < 0) {
+      throw 'negative index';
     }
     this.children.splice(index, 1);
   }
@@ -32,30 +33,30 @@ export class ViewMock {
     this.bindingContext = bindingContext;
     this.overrideContext = overrideContext;
   }
-  attached(){}
-  detached(){}
-  unbind(){}
-  returnToCache(){}
+  attached() {}
+  detached() {}
+  unbind() {}
+  returnToCache() {}
 }
 
 export class BoundViewFactoryMock {
   _viewsRequireLifecycle = true;
-  create(){
-    return { bind(){} };
-  };
-  removeAll(){};
+  create() {
+    return { bind() {} };
+  }
+  removeAll() {}
 }
 
 export class ViewFactoryMock {
   _viewsRequireLifecycle = true;
-  create(){
+  create() {
     return new ViewMock();
   }
 }
 
 export class ArrayObserverMock {
-  subscribe(){};
-  unsubscribe(){};
+  subscribe() {}
+  unsubscribe() {}
 }
 
 export class RepeatStrategyMock {
diff --git a/test/number-repeat-strategy.spec.ts b/test/number-repeat-strategy.spec.ts
index 758a81c..2c7aa67 100644
--- a/test/number-repeat-strategy.spec.ts
+++ b/test/number-repeat-strategy.spec.ts
@@ -6,7 +6,7 @@ import {Container} from 'aurelia-dependency-injection';
 import {Repeat} from '../src/repeat';
 import {RepeatStrategyLocator} from '../src/repeat-strategy-locator';
 import {NumberRepeatStrategy} from '../src/number-repeat-strategy';
-import {ViewSlotMock, BoundViewFactoryMock, RepeatStrategyMock, ViewMock, ArrayObserverMock, ViewFactoryMock, instructionMock, viewResourcesMock} from './mocks';
+import {ViewSlotMock, BoundViewFactoryMock, RepeatStrategyMock, ViewFactoryMock, instructionMock, viewResourcesMock} from './mocks';
 import {bootstrap} from 'aurelia-bootstrapper';
 
 describe('NumberRepeatStrategy', () => {
@@ -27,7 +27,7 @@ describe('NumberRepeatStrategy', () => {
     container.registerInstance(ObserverLocator, observerLocator);
     container.registerInstance(RepeatStrategyLocator, repeatStrategyLocator);
 
-    component = StageComponent.withResources().inView('<div repeat.for="item of items"></div>').boundTo({ items: [] })
+    component = StageComponent.withResources().inView('<div repeat.for="item of items"></div>').boundTo({ items: [] });
 
     component.create(bootstrap).then(() => {
       repeat = component.viewModel;
@@ -41,7 +41,7 @@ describe('NumberRepeatStrategy', () => {
 
   describe('instanceChanged', () => {
     beforeEach(() => {
-      repeat = new Repeat(new ViewFactoryMock(), instructionMock, viewSlot, viewResourcesMock, new ObserverLocator());
+      repeat = new Repeat(new ViewFactoryMock(), instructionMock, viewSlot, viewResourcesMock, new ObserverLocator(), null);
       let bindingContext = {};
       repeat.scope = { bindingContext, overrideContext: createOverrideContext(bindingContext) };
       viewSlot.children = [];
diff --git a/test/repeat.issue-378.spec.ts b/test/repeat.issue-378.spec.ts
new file mode 100644
index 0000000..df1d14b
--- /dev/null
+++ b/test/repeat.issue-378.spec.ts
@@ -0,0 +1,248 @@
+import './setup';
+import * as AuBinding from 'aurelia-binding';
+import { StageComponent, ComponentTester } from 'aurelia-testing';
+import { bootstrap } from 'aurelia-bootstrapper';
+import { waitForFrames } from './test-utilities';
+import { Repeat } from '../src/repeat';
+
+// https://github.com/aurelia/templating-resources/issues/378
+describe('repeat.issue-378.spec.ts', () => {
+  it('extracts matcher binding corectly when matcher is on repeated element', async () => {
+    const model = new class {
+      products = [
+        { id: 0, name: 'Motherboard' },
+        { id: 1, name: 'CPU' },
+        { id: 2, name: 'Memory' }
+      ];
+
+      productMatcher = (a, b) => {
+        return a.id === b.id;
+      }
+
+      selectedProduct = { id: 1, name: 'CPU' };
+    };
+    const component: ComponentTester<Repeat> = StageComponent
+      .withResources()
+      .inView(`<label repeat.for="product of products" matcher.bind="productMatcher">
+          \${product.id} - \${product.name}
+        </label>`)
+      .boundTo(model);
+
+    await component.create(bootstrap);
+
+    const matcherBinding = component.viewModel.matcherBinding;
+    expect(matcherBinding).not.toBe(undefined, 'matcher binding should have existed');
+    expect(matcherBinding instanceof (AuBinding as any).BindingExpression).toBe(true, 'it should have been a binding expression');
+
+    // verify that it leaves attribute intact
+    // even when binding expression is extracted
+    // todo: should it remove it?
+    expect(
+      Array.from(component['host'].querySelectorAll('label'))
+        .filter((label: HTMLElement) => label.hasAttribute('matcher.bind')).length).toBe(3);
+  });
+
+  it('does not extracts matcher binding when matcher is on repeated <template/> element', async () => {
+    const model = new class {
+      products = [
+        { id: 0, name: 'Motherboard' },
+        { id: 1, name: 'CPU' },
+        { id: 2, name: 'Memory' }
+      ];
+
+      productMatcher = (a, b) => {
+        return a.id === b.id;
+      }
+
+      selectedProduct = { id: 1, name: 'CPU' };
+    };
+    const component: ComponentTester<Repeat> = StageComponent
+      .withResources()
+      .inView(`<template repeat.for="product of products" matcher.bind="productMatcher">
+          <span>\${product.id} - \${product.name}</span>
+        </template>`)
+      .boundTo(model);
+
+    await component.create(bootstrap);
+
+    const matcherBinding = component.viewModel.matcherBinding;
+    expect(matcherBinding).toBe(undefined, 'matcher binding should have existed');
+
+  });
+
+  it('works with <div repeat[Array] /> -->> <... matcher />', async () => {
+    Repeat.useInnerMatcher = false;
+    const model = new class {
+      products = [
+        { id: 0, name: 'Motherboard' },
+        { id: 1, name: 'CPU' },
+        { id: 2, name: 'Memory' }
+      ];
+
+      productMatcher = (a, b) => {
+        return a.id === b.id;
+      }
+
+      selectedProduct = { id: 1, name: 'CPU' };
+    };
+    const component = StageComponent
+      .withResources()
+      .inView(`<label repeat.for="product of products">
+          <input
+            type="radio"
+            name="group2"
+            model.bind="product"
+            matcher.bind="productMatcher"
+            checked.bind="selectedProduct" />
+          \${product.id} - \${product.name}
+        </label>`)
+      .boundTo(model);
+
+    await component.create(bootstrap);
+    const radios = document.querySelectorAll('input[type=radio]') as ArrayLike<HTMLInputElement>;
+    expect(radios.length).toBe(3);
+    expect(radios[1].checked).toBe(true);
+
+    radios[2].click();
+    await waitForFrames(1);
+
+    expect(radios[1].checked).toBe(false);
+    expect(radios[2].checked).toBe(true);
+    expect(model.selectedProduct).toBe(model.products[2]);
+
+    component.dispose();
+    Repeat.useInnerMatcher = true;
+  });
+
+  it('works with <div repeat[Map] /> -->> <... matcher />', async () => {
+    Repeat.useInnerMatcher = false;
+    const model = new class {
+      products = new Map([
+        [0, 'Motherboard'],
+        [1, 'CPU'],
+        [2, 'Memory']
+      ]);
+
+      productMatcher = (a: [number, string], b: [number, string]) => {
+        return a[0] === b[0];
+      }
+
+      selectedProduct = [1, 'CPU'];
+    };
+    const component = StageComponent
+      .withResources()
+      .inView(`<label repeat.for="[id, name] of products">
+          <input
+            type="radio"
+            name="group2"
+            model.bind="[id, name]"
+            matcher.bind="productMatcher"
+            checked.bind="selectedProduct" />
+          \${id} - \${name}
+        </label>`)
+      .boundTo(model);
+
+    await component.create(bootstrap);
+    const radios = document.querySelectorAll('input[type=radio]') as ArrayLike<HTMLInputElement>;
+    expect(radios.length).toBe(3);
+    expect(radios[1].checked).toBe(true);
+
+    radios[2].click();
+    await waitForFrames(1);
+
+    expect(radios[1].checked).toBe(false);
+    expect(radios[2].checked).toBe(true);
+    expect(model.selectedProduct).toEqual(Array.from(model.products)[2]);
+
+    component.dispose();
+    Repeat.useInnerMatcher = true;
+  });
+
+  describe('QA', () => {
+    it('works with matcher', async () => {
+      const model = new class App {
+
+        components: any[];
+        componentsAlternative: any;
+        objComponents: any[];
+        objComponentsAlternative: any[];
+        amatcher: (a: any, b: any) => boolean;
+        matcherCallCount: number;
+
+        constructor() {
+          this.components = [];
+          for (let i = 1; i <= 10; i++) {
+            this.components.push(i);
+          }
+          this.componentsAlternative = this.components.slice(0).reverse();
+
+          this.objComponents = [];
+          this.objComponentsAlternative = [];
+          for (let i = 1; i <= 10; i++) {
+            this.objComponents.push({ id: i, text: i, cls: 'obj-comp' });
+            this.objComponentsAlternative.push({ id: i, text: i - 1, cls: 'obj-comp-alt' });
+          }
+
+          this.matcherCallCount = 0;
+          this.amatcher = (a, b) => {
+            this.matcherCallCount++;
+            return a.id === b.id;
+          };
+        }
+
+        swapArrays() {
+          [this.components, this.componentsAlternative] = [this.componentsAlternative, this.components];
+          [this.objComponents, this.objComponentsAlternative] = [this.objComponentsAlternative, this.objComponents];
+        }
+
+        reverse() {
+          this.components = this.components.slice(0).reverse();
+          this.objComponents = this.objComponents.slice(0).reverse();
+        }
+      };
+      const view =
+        `<button click.delegate="swapArrays()">Swap arrays</button>
+        <br>
+        <dummy
+          repeat.for="obj of objComponents"
+          matcher.bind="amatcher"
+          class="a-matcher"
+          obj.bind="obj"
+          index.bind="$index">
+        </dummy>`;
+
+      const component = StageComponent
+        .withResources([
+          class Dummy {
+            static $view = `<template class="\${obj.cls}" dataa-counter="\${counter}">[\${index}]</template>`;
+            static $resource = {
+              name: 'dummy',
+              bindables: ['obj', 'index']
+            };
+
+            counter = 0;
+
+            attached() {
+              this.counter++;
+            }
+          }
+        ] as any)
+        .inView(view)
+        .boundTo(model);
+
+      await component.create(bootstrap);
+
+      const viewModel = component.viewModel;
+      expect(viewModel.matcher()).toBe(model.amatcher);
+      expect(document.querySelectorAll('.obj-comp').length).toBe(10);
+
+      model.swapArrays();
+      await waitForFrames(1);
+
+      expect(document.querySelectorAll('.obj-comp-alt').length).toBe(10);
+      expect(model.matcherCallCount).toBeGreaterThan(40);
+
+      component.dispose();
+    });
+  });
+});
diff --git a/test/repeat.spec.ts b/test/repeat.spec.ts
index a5bed13..e613146 100644
--- a/test/repeat.spec.ts
+++ b/test/repeat.spec.ts
@@ -43,7 +43,7 @@ describe('repeat', () => {
       view1 = new ViewMock();
       view2 = new ViewMock();
       viewSlot.children = [view1, view2];
-      spyOn(repeatStrategyLocator, 'getStrategy').and.callFake(() => { return repeatStrategyMock});
+      spyOn(repeatStrategyLocator, 'getStrategy').and.callFake(() => { return repeatStrategyMock; });
     });
 
     it('should do nothing if no items provided', () => {
@@ -69,7 +69,7 @@ describe('repeat', () => {
       spyOn(viewSlot, 'removeAll');
       repeat.unbind();
 
-      expect(viewSlot.removeAll).toHaveBeenCalled(); //With(true, true);
+      expect(viewSlot.removeAll).toHaveBeenCalled(); // With(true, true);
     });
 
     it('should unsubscribe collection', () => {
@@ -101,7 +101,7 @@ describe('repeat', () => {
 
   describe('itemsChanged', () => {
     beforeEach(() => {
-      spyOn(repeatStrategyLocator, 'getStrategy').and.callFake(() => { return repeatStrategyMock});
+      spyOn(repeatStrategyLocator, 'getStrategy').and.callFake(() => { return repeatStrategyMock; });
     });
 
     it('should unsubscribe collection', () => {
@@ -124,7 +124,7 @@ describe('repeat', () => {
     it('should subscribe to changes', () => {
       repeat.items = ['foo', 'bar'];
       let collectionObserver = new ArrayObserverMock();
-      spyOn(repeatStrategyMock, 'getCollectionObserver').and.callFake(() => { return collectionObserver });
+      spyOn(repeatStrategyMock, 'getCollectionObserver').and.callFake(() => { return collectionObserver; });
       spyOn(collectionObserver, 'subscribe');
       repeat.itemsChanged();
 
diff --git a/test/sanitize-html.spec.ts b/test/sanitize-html.spec.ts
index 6170cb9..084705f 100644
--- a/test/sanitize-html.spec.ts
+++ b/test/sanitize-html.spec.ts
@@ -3,14 +3,14 @@ import {SanitizeHTMLValueConverter} from '../src/sanitize-html';
 import {HTMLSanitizer} from '../src/html-sanitizer';
 
 describe('SanitizeHtmlValueConverter', () => {
-  var converter;
+  let converter;
 
   beforeEach(() => {
     converter = new SanitizeHTMLValueConverter(new HTMLSanitizer());
   });
 
   it('defaultSanitizer should remove script tags', () => {
-    var a = '<script src="http://www.evil.org"></script>',
+    let a = '<script src="http://www.evil.org"></script>',
         b = '<div><script src="http://www.evil.org"></script></div>',
         c = 'foo <script src="http://www.evil.org"></script> bar',
         d = '<div></div>',
diff --git a/test/test-interfaces.ts b/test/test-interfaces.ts
index be271db..228a703 100644
--- a/test/test-interfaces.ts
+++ b/test/test-interfaces.ts
@@ -1,5 +1,8 @@
 import 'aurelia-templating';
 
+/**
+ * @internal
+ */
 declare module 'aurelia-templating' {
   interface HtmlBehaviorResource {
     elementName: string | null;
diff --git a/test/test-utilities.ts b/test/test-utilities.ts
new file mode 100644
index 0000000..30485dd
--- /dev/null
+++ b/test/test-utilities.ts
@@ -0,0 +1,7 @@
+
+
+export const waitForFrames = async (frameCount: number) => {
+  while (frameCount-- > 0) {
+    await new Promise(r => requestAnimationFrame(r));
+  }
+};