Skip to content

Commit

Permalink
feat(debounce-throttle): flush via signals (#1739)
Browse files Browse the repository at this point in the history
  • Loading branch information
bigopon committed Apr 13, 2023
1 parent 8cf87af commit af238a9
Show file tree
Hide file tree
Showing 13 changed files with 258 additions and 127 deletions.
4 changes: 3 additions & 1 deletion docs/user-docs/components/shadow-dom-and-slots.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ The `<slot>` element comes with an event based way to listen to its changes. Thi
<slot slotchange.trigger="handleSlotChange($event.target.assignedNodes())"></slot>
```
{% endcode %}

{% code title="my-app.ts overflow="wrap" lineNumbers="true" %}
```typescript
class MyApp {
Expand Down Expand Up @@ -126,8 +127,9 @@ the property being decorated, example: `divs` -> `divsChanged`
| `@children('div') prop` | Observe mutation, and select only `div` elements |

{% hint style="info" %}

Note: the `@children` decorator wont update if the children of a slotted node change — only if you change (e.g. add or delete) the actual nodes themselves.
{% %}
{% endhint %}

## Au-slot

Expand Down
28 changes: 27 additions & 1 deletion docs/user-docs/templates/binding-behaviors.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,19 @@ The throttle behavior is particularly useful when binding events to methods on y
<div mousemove.delegate="mouseMove($event) & throttle"></div>
```

### Flush pending throttled calls

Sometimes it's desirable to forcefully run the throttled update, so that the application syncs the latest values. This can happen in a form, when a user previously was typing into a throttled form field, and hit tab key to go to the next field, as an example.
The `throttle` binding behavior supports this scenario via signal. These signals can be added via the 2nd parameter, like the following example:

{% code title="my-app.html" lineNumbers="true" overflow="wrap" %}
```html
<input value.bind="value & throttle :200 :`finishTyping`" blur.trigger="signaler.dispatchSignal('finishTyping')">
<!-- or it can be a list of signals -->
<input value.bind="value & throttle :200 :[`finishTyping`, `newUpdate`]">
```
{% endcode %}

## Debounce

The debounce binding behavior is another rate-limiting binding behavior. Debounce prevents the binding from being updated until a specified interval has passed without any changes.
Expand Down Expand Up @@ -70,7 +83,20 @@ Here's another example with the `mousemove` event:
<div mousemove.delegate="mouseMove($event) & debounce:500"></div>
```

## **UpdateTrigger**
### Flush pending debounced calls

Sometimes it's desirable to forcefully run the throttled update, so that the application syncs the latest values. This can happen in a form, when a user previously was typing into a throttled form field, and hit tab key to go to the next field, as an example.
Similar to the [`throttle` binding behavior](#throttle), The `debounce` binding behavior supports this scenario via signal. These signals can be added via the 2nd parameter, like the following example:

{% code title="my-app.html" lineNumbers="true" overflow="wrap" %}
```html
<input value.bind="value & debounce :200 :`finishTyping`" blur.trigger="signaler.dispatchSignal('finishTyping')">
<!-- or it can be a list of signals -->
<input value.bind="value & debounce :200 :[`finishTyping`, `newUpdate`]">
```
{% endcode %}

## UpdateTrigger

Update trigger allows you to override the input events that cause the element's value to be written to the view-model. The default events are `change` and `input`.

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { TestContext, assert, createFixture } from '@aurelia/testing';
import { BindingMode, customElement, bindable, Aurelia } from '@aurelia/runtime-html';
import { delegateSyntax } from '@aurelia/compat-v1';
import { ISignaler } from '@aurelia/runtime';

async function wait(ms: number): Promise<void> {
await new Promise(resolve => setTimeout(resolve, ms));
Expand Down Expand Up @@ -428,67 +429,115 @@ describe('3-runtime-html/binding-commands.throttle-debounce.spec.ts', function (
assert.strictEqual(a, 3);
assert.strictEqual(aCount, 2);
});

it('updates let on flush signals', async function () {
let a = void 0;
let aCount = 0;
const { ctx, component } = createFixture(
'<let to-binding-context a.bind="b & debounce :25 : `hurry`">',
class {
set a(v: unknown) {
aCount++;
a = v;
}

b = 1;
}
);

assert.strictEqual(a, 1);
assert.strictEqual(aCount, 1);

component.b = 2;
assert.strictEqual(a, 1, 'debounce holds 2 from propagating');
assert.strictEqual(aCount, 1);

component.b = 3;
assert.strictEqual(a, 1, 'debounce holds 3 from propagating');
assert.strictEqual(aCount, 1);

ctx.container.get(ISignaler).dispatchSignal('hurry');

assert.strictEqual(a, 3);
assert.strictEqual(aCount, 2);
});

it('updates let on flush multiple signals', async function () {
let a = void 0;
let aCount = 0;
const { ctx, component } = createFixture(
'<let to-binding-context a.bind="b & debounce :25 : [`hurry`, `running`]">',
class {
set a(v: unknown) {
aCount++;
a = v;
}

b = 1;
}
);

assert.strictEqual(a, 1);
assert.strictEqual(aCount, 1);

component.b = 2;
assert.strictEqual(a, 1, 'debounce holds 2 from propagating');
assert.strictEqual(aCount, 1);

component.b = 3;
assert.strictEqual(a, 1, 'debounce holds 3 from propagating');
assert.strictEqual(aCount, 1);

ctx.container.get(ISignaler).dispatchSignal('running');

assert.strictEqual(a, 3);
assert.strictEqual(aCount, 2);
});
});

describe('throttle', function () {
// this following test should work, if we ever bring back the v1 behavior:
// - throttle target -> source in 2 way
// - throttle source -> target in 1 way
// ============================================
// it('works with [oneWay] binding to elements', async function () {
// @customElement({
// name: 'app',
// template: `<input ref="receiver" value.to-view="value & throttle:25">`,
// })
// class App {
// public value: string = '0';
// public receiver: HTMLInputElement;
// }

// const { au, host, ctx } = createFixture();

// const component = new App();
// au.app({ component, host });
// await au.start();

// const receiver = component.receiver;
// component.value = '1';

// assert.strictEqual(receiver.value, '0', 'target value pre #1');
// ctx.platform.domWriteQueue.flush();
// assert.strictEqual(receiver.value, '1', 'target value #1');
it('works with [oneWay] binding to elements', async function () {
@customElement({
name: 'app',
template: `<input ref="receiver" value.to-view="value & throttle:25">`,
})
class App {
public value: string = '0';
public receiver: HTMLInputElement;
}

// component.value = '2';
const { component, flush } = createFixture('<input ref="receiver" value.to-view="value & throttle:25">', App);

// assert.strictEqual(receiver.value, '1', 'target value pre #2');
// ctx.platform.domWriteQueue.flush();
// assert.strictEqual(receiver.value, '1', 'target value #2');
// await wait(20);
// assert.strictEqual(receiver.value, '1', 'target value pre #2 + wait(20)');
// ctx.platform.domWriteQueue.flush();
// assert.strictEqual(receiver.value, '1', 'target value #2 + wait(20)');

// component.value = '3';
const receiver = component.receiver;
component.value = '1';

// assert.strictEqual(receiver.value, '1', 'target value pre #3');
// ctx.platform.domWriteQueue.flush();
// assert.strictEqual(receiver.value, '1', 'target value #3');
assert.strictEqual(receiver.value, '0', 'target value pre #1');
flush();
assert.strictEqual(receiver.value, '1', 'target value #1');

// await wait(10);
// assert.strictEqual(receiver.value, '1', 'target value pre #3 + wait(10)');
// ctx.platform.domWriteQueue.flush();
// assert.strictEqual(receiver.value, '3', 'target value #3 + wait(10)');
component.value = '2';

// await wait(50);
assert.strictEqual(receiver.value, '1', 'target value pre #2');
flush();
assert.strictEqual(receiver.value, '1', 'target value #2');
await wait(20);
assert.strictEqual(receiver.value, '1', 'target value pre #2 + wait(20)');
flush();
assert.strictEqual(receiver.value, '1', 'target value #2 + wait(20)');

// assert.strictEqual(receiver.value, '3', 'target value pre #4');
// ctx.platform.domWriteQueue.flush();
// assert.strictEqual(receiver.value, '3', 'target value #4');
component.value = '3';

// await au.stop();
assert.strictEqual(receiver.value, '1', 'target value pre #3');
flush();
assert.strictEqual(receiver.value, '1', 'target value #3');

// au.dispose();
// });
await wait(10);
assert.strictEqual(receiver.value, '3', 'target value pre #3 + wait(10) (total wait 30 > 25)');
});

it('works with [twoWay] bindings to other components', async function () {
@customElement({
Expand All @@ -500,21 +549,12 @@ describe('3-runtime-html/binding-commands.throttle-debounce.spec.ts', function (
public value: string = '0';
}

@customElement({
name: 'app',
template: `<au-receiver view-model.ref="receiver" value.bind="value & throttle:25"></au-receiver>`,
dependencies: [Receiver],
})
class App {
public value: string = '0';
public receiver: Receiver;
}

const { au, host } = $createFixture();

const component = new App();
au.app({ component, host });
await au.start();
const { component } = createFixture(`<au-receiver view-model.ref="receiver" value.bind="value & throttle:25"></au-receiver>`, App, [Receiver]);

const receiver = component.receiver;
component.value = '1';
Expand Down Expand Up @@ -581,10 +621,56 @@ describe('3-runtime-html/binding-commands.throttle-debounce.spec.ts', function (
// it('works with toView bindings to other [components]',
// for similar scenario
assert.strictEqual(receiver.value, '6', `change 6 propagated`); // change from line 555 above
});

await au.stop();
it('flushes on signals', function () {
class App {
public value: string = '0';
public receiver: HTMLInputElement;
}

au.dispose();
const { ctx, component, flush } = createFixture('<input ref="receiver" value.to-view="value & throttle:25:`hurry`">', App);
const receiver = component.receiver;
const signaler = ctx.container.get(ISignaler);

component.value = '1';
// this flush hasn't set a time for throttle yet, since it' only the first run of throttle
flush();
assert.strictEqual(receiver.value, '1');

component.value = '2';
assert.strictEqual(receiver.value, '1');
// this flush is gonna call a throttled updateTarget, since we just called in in the flush above
flush();
assert.strictEqual(receiver.value, '1');

signaler.dispatchSignal('hurry');
assert.strictEqual(receiver.value, '2');
});

it('flushes on multiple signals', function () {
class App {
public value: string = '0';
public receiver: HTMLInputElement;
}

const { ctx, component, flush } = createFixture('<input ref="receiver" value.to-view="value & throttle:25:[`now`, `hurry`]">', App);
const receiver = component.receiver;
const signaler = ctx.container.get(ISignaler);

component.value = '1';
// this flush hasn't set a time for throttle yet, since it' only the first run of throttle
flush();
assert.strictEqual(receiver.value, '1');

component.value = '2';
assert.strictEqual(receiver.value, '1');
// this flush is gonna call a throttled updateTarget, since we just called in in the flush above
flush();
assert.strictEqual(receiver.value, '1');

signaler.dispatchSignal('hurry');
assert.strictEqual(receiver.value, '2');
});
});

Expand Down
Loading

0 comments on commit af238a9

Please sign in to comment.