Skip to content

Commit

Permalink
feat(context): add events to ContextView
Browse files Browse the repository at this point in the history
Extension points that use ContextView can then listen on events to update
its state/cache beyond the view accordingly.
  • Loading branch information
raymondfeng committed Mar 11, 2019
1 parent c3c5dab commit fb10efc
Show file tree
Hide file tree
Showing 3 changed files with 104 additions and 4 deletions.
38 changes: 38 additions & 0 deletions docs/site/Context.md
Original file line number Diff line number Diff line change
Expand Up @@ -492,3 +492,41 @@ the context with `listen()`.
If your dependency needs to follow the context for values from bindings matching
a filter, use [`@inject.view`](Decorators_inject.md#@inject.view) for dependency
injection.

### ContextView events

A `ContextView` object can emit one of the following events:

- 'refresh': when the view is refreshed as bindings are added/removed
- 'resolve': when the cached values are resolved and updated
- 'close': when the view is closed (stopped observing context events)

Such as events can be used to update other states/cached values other than the
values watched by the `ContextView` object itself. For example:

```ts
class MyController {
private _total: number | undefined = undefined;
constructor(
@inject.view(filterByTag('counter'))
private taggedAsFoo: ContextView<Counter>,
) {
// Invalidate cached `_total` if the view is refreshed
taggedAsFoo.on('refresh', () => {
this._total = undefined;
});
}

async total() {
if (this._total != null) return this._total;
// Calculate the total of all counters
const counters = await this.taggedAsFoo.values();
let result = 0;
for (const c of counters) {
result += c.value;
}
this._total = result;
return this._total;
}
}
```
43 changes: 43 additions & 0 deletions packages/context/src/__tests__/unit/context-view.unit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,49 @@ describe('ContextView', () => {
expect(await taggedAsFoo.values()).to.eql(['BAR', 'FOO']);
});

describe('EventEmitter', () => {
let events: string[] = [];

beforeEach(setupListeners);

it('emits close', () => {
taggedAsFoo.close();
expect(events).to.eql(['close']);
// 2nd close does not emit `close` as it's closed
taggedAsFoo.close();
expect(events).to.eql(['close']);
});

it('emits refresh', () => {
taggedAsFoo.refresh();
expect(events).to.eql(['refresh']);
});

it('emits resolve', async () => {
await taggedAsFoo.values();
expect(events).to.eql(['resolve']);
// Second call does not resolve as values are cached
await taggedAsFoo.values();
expect(events).to.eql(['resolve']);
});

it('emits refresh & resolve when bindings are changed', async () => {
server
.bind('xyz')
.to('XYZ')
.tag('foo');
await taggedAsFoo.values();
expect(events).to.eql(['refresh', 'resolve']);
});

function setupListeners() {
events = [];
['open', 'close', 'refresh', 'resolve'].forEach(t =>
taggedAsFoo.on(t, () => events.push(t)),
);
}
});

function givenContextView() {
bindings = [];
givenContext();
Expand Down
27 changes: 23 additions & 4 deletions packages/context/src/context-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
// License text available at https://opensource.org/licenses/MIT

import * as debugFactory from 'debug';
import {EventEmitter} from 'events';
import {promisify} from 'util';
import {Binding} from './binding';
import {BindingFilter} from './binding-filter';
Expand All @@ -28,23 +29,34 @@ const nextTick = promisify(process.nextTick);
* points. For example, the RestServer can react to `controller` bindings even
* they are added/removed/updated after the application starts.
*
* `ContextView` is an event emitter that emits the following events:
* - 'close': when the view is closed (stopped observing context events)
* - 'refresh': when the view is refreshed as bindings are added/removed
* - 'resolve': when the cached values are resolved and updated
*/
export class ContextView<T = unknown> implements ContextObserver {
export class ContextView<T = unknown> extends EventEmitter
implements ContextObserver {
protected _cachedBindings: Readonly<Binding<T>>[] | undefined;
protected _cachedValues: T[] | undefined;
private _subscription: Subscription | undefined;

constructor(
protected readonly context: Context,
public readonly filter: BindingFilter,
) {}
) {
super();
}

/**
* Start listening events from the context
*/
open() {
debug('Start listening on changes of context %s', this.context.name);
return (this._subscription = this.context.subscribe(this));
if (this.context.isSubscribed(this)) {
return this._subscription;
}
this._subscription = this.context.subscribe(this);
return this._subscription;
}

/**
Expand All @@ -55,6 +67,7 @@ export class ContextView<T = unknown> implements ContextObserver {
if (!this._subscription || this._subscription.closed) return;
this._subscription.unsubscribe();
this._subscription = undefined;
this.emit('close');
}

/**
Expand Down Expand Up @@ -92,6 +105,7 @@ export class ContextView<T = unknown> implements ContextObserver {
debug('Refreshing the view by invalidating cache');
this._cachedBindings = undefined;
this._cachedValues = undefined;
this.emit('refresh');
}

/**
Expand All @@ -105,9 +119,14 @@ export class ContextView<T = unknown> implements ContextObserver {
return b.getValue(this.context, ResolutionSession.fork(session));
});
if (isPromiseLike(result)) {
result = result.then(values => (this._cachedValues = values));
result = result.then(values => {
this._cachedValues = values;
this.emit('resolve', values);
return values;
});
} else {
this._cachedValues = result;
this.emit('resolve', result);
}
return result;
}
Expand Down

0 comments on commit fb10efc

Please sign in to comment.