Repository: aurelia/templating-resources
Pull request #396 (2020-09-06 10:24:08)

fix(repeat): extract matcher once
Thanks to @Seebiscuit for the ping at https://github.com/aurelia/templating-resources/issues/295

cc @EisenbergEffect @fkleuver 


Patch:
diff --git a/src/repeat.ts b/src/repeat.ts
index 85b1428..a08119f 100644
--- a/src/repeat.ts
+++ b/src/repeat.ts
@@ -22,6 +22,8 @@ import {
 import {viewsRequireLifecycle} from './analyze-view-factory';
 import {AbstractRepeater} from './abstract-repeater';
 
+const matcherExtractionMarker = '__marker_extracted__';
+
 /**
  * Binding to iterate over iterable objects (Array, Map and Number) to genereate a template for each iteration.
  */
@@ -135,7 +137,11 @@ export class Repeat extends AbstractRepeater {
   */
   bind(bindingContext, overrideContext) {
     this.scope = { bindingContext, overrideContext };
-    this.matcherBinding = this._captureAndRemoveMatcherBinding();
+    const instruction = this.instruction;
+    if (!(matcherExtractionMarker in instruction)) {
+      instruction[matcherExtractionMarker] = this._captureAndRemoveMatcherBinding();
+    }
+    this.matcherBinding = instruction[matcherExtractionMarker];
     this.itemsChanged();
   }
 


Test patch:
diff --git a/test/repeat.issue-295.spec.ts b/test/repeat.issue-295.spec.ts
new file mode 100644
index 0000000..c9e4f7c
--- /dev/null
+++ b/test/repeat.issue-295.spec.ts
@@ -0,0 +1,106 @@
+import './setup';
+import { StageComponent, ComponentTester } from 'aurelia-testing';
+import { bootstrap } from 'aurelia-bootstrapper';
+import { waitForFrames } from './test-utilities';
+import { Repeat } from '../src/repeat';
+import { customElement, inlineView, bindable } from 'aurelia-templating';
+
+// https://github.com/aurelia/templating-resources/issues/295
+describe('repeat.issue-295.spec.ts', () => {
+  it('extracts matcher binding corectly when bind/unbind', async () => {
+    let matcherCallCount = 0;
+
+    @customElement('sample-element')
+    @inlineView('<template></template>')
+    class SampleElement {
+      @bindable()
+      item: any;
+
+      // the issue happesn only when views inside a repeat[of an array] require lifecycle
+      // need bind as a trigger
+      bind() {/*  */}
+    }
+
+    @customElement('matcher-container')
+    @inlineView(
+      `<template>
+        <div repeat.for="item of data" matcher.bind="matcher" class="item">
+          <sample-element item.bind="item"></sample-element>
+        </div>
+
+        <button click.delegate="removeElement()">remove item</button>
+        <button click.delegate="reset()">reset</button>
+      </template>`
+    )
+    class MatcherContainer {
+      data: any[] = [];
+
+      bind() {
+        this.data = [1, 2, 3];
+      }
+
+      removeElement() {
+        this.data = this.data.filter(item => item !== 1);
+      }
+
+      reset() {
+        matcherCallCount = 0;
+        this.data = [1, 2, 3];
+      }
+
+      matcher(oldItem, newItem) {
+        matcherCallCount++;
+        return oldItem === newItem;
+      }
+    }
+
+    const model = new class {
+      showContainer = true;
+    };
+    const component: ComponentTester<Repeat> = StageComponent
+      .withResources([MatcherContainer, SampleElement] as any)
+      .inView(`<matcher-container if.bind="showContainer">`)
+      .boundTo(model);
+
+    await component.create(bootstrap);
+
+    // step 1. verify everything works as normal
+    expect(matcherCallCount).toBe(0);
+    const host = component['host'] as HTMLElement;
+    let buttons = host.querySelectorAll('button') as ArrayLike<HTMLButtonElement>;
+    buttons[0].click();
+    expect(host.querySelectorAll('.item').length).toBe(3);
+    await waitForFrames(1);
+    expect(host.querySelectorAll('.item').length).toBe(2);
+    expect(matcherCallCount).toBeGreaterThanOrEqual(3);
+
+    // step 2. verify matcher is working by changing the repeat data
+    buttons[1].click();
+    expect(matcherCallCount).toBe(0);
+    await waitForFrames(1);
+    expect(host.querySelectorAll('.item').length).toBe(3);
+    expect(matcherCallCount).toBeGreaterThanOrEqual(3);
+
+    // step 3. toggle the if and reset counter
+    // this is needed to verify matcher working again later
+    matcherCallCount = 0;
+    model.showContainer = false;
+    await waitForFrames(1);
+    expect(host.querySelectorAll('.item').length).toBe(0);
+    expect(matcherCallCount).toBe(0);
+
+    // step 4. preparation similar to step 1 to test matcher working again in next step
+    model.showContainer = true;
+    await waitForFrames(1);
+    expect(host.querySelectorAll('.item').length).toBe(3);
+    expect(matcherCallCount).toBe(0);
+
+    // step 5. verify matcher working again after toggling if on
+    buttons = host.querySelectorAll('button') as ArrayLike<HTMLButtonElement>;
+    buttons[0].click();
+    expect(host.querySelectorAll('.item').length).toBe(3);
+    await waitForFrames(1);
+    expect(host.querySelectorAll('.item').length).toBe(2);
+    expect(matcherCallCount).toBeGreaterThanOrEqual(3);
+  });
+});