diff --git a/docs-src/src/content/pages/package/casl-angular/en.md b/docs-src/src/content/pages/package/casl-angular/en.md
index 6c15d0ef7..4831c1e96 100644
--- a/docs-src/src/content/pages/package/casl-angular/en.md
+++ b/docs-src/src/content/pages/package/casl-angular/en.md
@@ -156,53 +156,34 @@ Directive cannot be used to pass values into inputs of other components. For exa
```
-To track status of directive implementation, check [#276](https://github.com/stalniy/casl/issues/276)
-
## Performance considerations
-Due to [open feature in Angular](https://github.com/angular/angular/issues/15041), pipes were designed to be [impure](https://angular.io/guide/pipes#impure-pipes). This should work pretty fine for majority of cases but may become a bottleneck if you have more than 50 rules (depending on application size and computer characteristics).
+There are 2 pipes in `@casl/angular`:
-Don't worry, there are several strategies which you can pick to make things fast when they become slower:
+* `able` - impure pipe
+* `ablePure` - pure pipe
-* use memoization, either on `Ability#can` or on `AblePipe#transform` method
-* if you use immutable objects, you can extend existing pipe and make it pure
-* use `ChangeDectionStrategy.OnPush` on your components whenever possible
+So, when should we use which?
-To memoize results of `AblePipe`, you will need to create your own one and change its `transform` method to cache results. Also you will need to clear all memoized results when corresponding `Ability` instance is updated.
+> If you are in doubt, then use `ablePure` for action and subject type checks, and `able` for all others
-The similar strategy can be applied to `Ability` class. Don't forget to provide new pipe or `Ability` class in `AppModule`! For example
+According to Angular documentation pure pipes are called only if their arguments are changed. This means that you **can't use mutable objects with pure pipes** because changes in that objects don't trigger pure pipe re-evaluation. But a good thing is that Angular creates only single instance of a pure pipe for the whole app and reuses it across components, this way it safes component instantiation time and memory footprint.
-```ts
-import { NgModule } from '@angular/core';
-import { Ability } from '@casl/ability';
-import { MemoizedAbility } from './ability';
+Due to [open feature in Angular](https://github.com/angular/angular/issues/15041), we need to pass the result of `ablePure` pipe to `async` pipe. So, instead of
-@NgModule({
- // other configuration
- providers: [
- { provide: Ability, useValue: new MemoizedAbility() },
- { provide: PureAbility, useExisting: Ability },
- ]
-})
-export class AppModule {}
+```html
+
...
```
-or if you want to provide custom pipe:
+we need to write:
-```ts
-import { AblePipe } from '@casl/angular'
+```html
+
...
+```
-@Pipe({ name: 'able', pure: true })
-class PureAblePipe extends AblePipe {}
+> `ablePure` pipe returns an `Observable`, so `async` pipe can effectively unwrap it
-@NgModule({
- // other configuration
- declarations: [
- PureAblePipe
- ]
-})
-export class AppModule {}
-```
+For apps that mutate application state, we need to use impure `able` pipe as it can detect changes in object properties. Don't worry, checks by action and subject type are very fast and are done in O(1) time. The performance of checks by action and subject object are a bit slower and depend on the amount of rules for a particular subject type and used conditions but usually this won't become a bottle neck for the app.
## TypeScript support
diff --git a/packages/casl-angular/package.json b/packages/casl-angular/package.json
index 3482c4b52..addb7ba6a 100644
--- a/packages/casl-angular/package.json
+++ b/packages/casl-angular/package.json
@@ -20,7 +20,7 @@
"build.es6": "TARGET=es2015 BUILD=es6 npm run rollup",
"build.umd": "TARGET=es5 BUILD=umd npm run rollup",
"build.types": "ngc -p tsconfig.types.json",
- "rollup": "ngc --target $TARGET --outDir dist/$BUILD/tmp && LIB_MINIFY=false BUILD_TYPES=$BUILD USE_SRC_MAPS=1 rollup -c ../../tools/rollup.config.js -i dist/$BUILD/tmp/index.js -n casl.ng -g @angular/core:ng.core,@casl/ability:casl,tslib:tslib",
+ "rollup": "ngc --target $TARGET --outDir dist/$BUILD/tmp && LIB_MINIFY=false BUILD_TYPES=$BUILD USE_SRC_MAPS=1 rollup -c ../../tools/rollup.config.js -i dist/$BUILD/tmp/index.js -n casl.ng -g @angular/core:ng.core,@casl/ability:casl,tslib:tslib,rxjs:rxjs",
"postrollup": "rm -rf dist/$BUILD/tmp",
"test": "NODE_ENV=test jest --config ./jest.config.js",
"lint": "eslint --ext .ts,.js src/ spec/",
@@ -45,7 +45,8 @@
"peerDependencies": {
"@angular/core": "^9.0.0",
"tslib": "^1.9.0 || ^2.0.0",
- "@casl/ability": "^2.0.0 || ^3.0.0 || ^4.0.0"
+ "@casl/ability": "^2.0.0 || ^3.0.0 || ^4.0.0",
+ "rxjs": "^6.0.0"
},
"devDependencies": {
"@abraham/reflection": "^0.7.0",
diff --git a/packages/casl-angular/spec/module.spec.js b/packages/casl-angular/spec/module.spec.js
deleted file mode 100644
index 5f25e3966..000000000
--- a/packages/casl-angular/spec/module.spec.js
+++ /dev/null
@@ -1,81 +0,0 @@
-import { PureAbility, Ability } from '@casl/ability'
-import { TestBed } from '@angular/core/testing'
-import { AbilityModule } from '../dist/es6'
-import { App, Post } from './spec_helper'
-
-describe('Ability', () => {
- let fixture
-
- beforeEach(() => {
- TestBed.configureTestingModule({
- imports: [AbilityModule],
- declarations: [App],
- providers: [
- { provide: PureAbility, useFactory: () => new Ability() }
- ]
- })
- })
-
- afterEach(() => {
- if (fixture) {
- fixture.destroy()
- }
- })
-
- describe('module', () => {
- it('provides `can` pipe', () => {
- fixture = createComponent(App)
- expect(fixture.nativeElement.textContent).to.equal('false')
- })
-
- it('provides `able` pipe', () => {
- fixture = createComponent(App, { pipe: 'able' })
- expect(fixture.nativeElement.textContent).to.equal('false')
- })
- })
-
- describe('`can` pipe', () => {
- let ability
- let post
-
- beforeEach(() => {
- ability = TestBed.inject(PureAbility)
- post = new Post({ author: 'me' })
- })
-
- it('updates template when `ability` is updated', () => {
- fixture = createComponent(App, { post })
- ability.update([{ subject: Post.name, action: 'read' }])
- fixture.detectChanges()
-
- expect(fixture.nativeElement.textContent).to.equal('true')
- })
-
- describe('when abilities depends on object attribute', () => {
- beforeEach(() => {
- ability.update([{ subject: Post.name, action: 'read', conditions: { author: 'me' } }])
- fixture = createComponent(App, { post })
- fixture.detectChanges()
- })
-
- it('returns `true` if object attribute equals to specified value', () => {
- expect(fixture.nativeElement.textContent).to.equal('true')
- })
-
- it('updates template when object attribute is changed', () => {
- post.author = 'not me'
- fixture.detectChanges()
-
- expect(fixture.nativeElement.textContent).to.equal('false')
- })
- })
- })
-
- function createComponent(Type, inputs) {
- const cmp = TestBed.createComponent(Type)
- Object.assign(cmp.componentInstance, inputs)
- cmp.detectChanges()
-
- return cmp
- }
-})
diff --git a/packages/casl-angular/spec/pipes.e2e.spec.js b/packages/casl-angular/spec/pipes.e2e.spec.js
new file mode 100644
index 000000000..78de31821
--- /dev/null
+++ b/packages/casl-angular/spec/pipes.e2e.spec.js
@@ -0,0 +1,82 @@
+import { PureAbility } from '@casl/ability'
+import { TestBed } from '@angular/core/testing'
+import { createApp, createComponent, configureTestingModule, Post } from './spec_helper'
+
+const AppWithCanPipe = createApp('{{ post | can: \'read\' }}')
+const AppWithAblePipe = createApp('{{ \'read\' | able: post }}')
+const AppWithAblePurePipe = createApp('{{ \'read\' | ablePure: post | async }}')
+
+describe('Ability pipes', () => {
+ let fixture
+ let ability
+ let post
+
+ afterEach(() => {
+ if (fixture) {
+ fixture.destroy()
+ }
+ })
+
+ describe('module', () => {
+ it('provides deprecated impure `can` pipe', () => {
+ configureTestingModule([AppWithCanPipe])
+ fixture = createComponent(AppWithCanPipe)
+ expect(fixture.nativeElement.textContent).to.equal('false')
+ })
+
+ it('provides impure `able` pipe', () => {
+ configureTestingModule([AppWithAblePipe])
+ fixture = createComponent(AppWithAblePipe)
+ expect(fixture.nativeElement.textContent).to.equal('false')
+ })
+ })
+
+ describe('`can` pipe', () => {
+ behavesLikeAbilityPipe(AppWithCanPipe)
+ })
+
+ describe('`able` pipe', () => {
+ behavesLikeAbilityPipe(AppWithAblePipe)
+ })
+
+ describe('`ablePure` pipe', () => {
+ behavesLikeAbilityPipe(AppWithAblePurePipe)
+ })
+
+ function behavesLikeAbilityPipe(App) {
+ beforeEach(() => {
+ configureTestingModule([App])
+ ability = TestBed.inject(PureAbility)
+ post = new Post({ author: 'me' })
+ })
+
+ it('updates template when `ability` is updated', () => {
+ fixture = createComponent(App, { post })
+ ability.update([{ subject: Post.name, action: 'read' }])
+ fixture.detectChanges()
+
+ expect(fixture.nativeElement.textContent).to.equal('true')
+ })
+
+ describe('when abilities depends on object attribute', () => {
+ beforeEach(() => {
+ ability.update([{ subject: Post.name, action: 'read', conditions: { author: 'me' } }])
+ fixture = createComponent(App, { post })
+ fixture.detectChanges()
+ })
+
+ it('returns `true` if object attribute equals to specified value', () => {
+ expect(fixture.nativeElement.textContent).to.equal('true')
+ })
+
+ if (App !== AppWithAblePurePipe) {
+ it('updates template when object attribute is changed', () => {
+ post.author = 'not me'
+ fixture.detectChanges()
+
+ expect(fixture.nativeElement.textContent).to.equal('false')
+ })
+ }
+ })
+ }
+})
diff --git a/packages/casl-angular/spec/spec_helper.js b/packages/casl-angular/spec/spec_helper.js
index 4070c8a4b..e49681387 100644
--- a/packages/casl-angular/spec/spec_helper.js
+++ b/packages/casl-angular/spec/spec_helper.js
@@ -10,19 +10,22 @@ import {
} from '@angular/platform-browser-dynamic/testing'
import { TestBed } from '@angular/core/testing'
import { Component } from '@angular/core'
+import { Ability, PureAbility } from '@casl/ability'
+import { AbilityModule } from '../dist/es6'
TestBed.initTestEnvironment(
BrowserDynamicTestingModule,
platformBrowserDynamicTesting()
)
-export class App {
+let appIndex = 0
+export const createApp = template => class App {
static get annotations() {
return [
new Component({
- selector: 'app-ability',
- template: '{{ pipe === "able" ? ("read" | able: post) : (post | can: "read") }}',
- inputs: ['post', 'pipe']
+ selector: `app-ability-${++appIndex}`,
+ template,
+ inputs: ['post']
})
]
}
@@ -33,3 +36,21 @@ export class Post {
Object.assign(this, attrs)
}
}
+
+export function createComponent(Type, inputs) {
+ const cmp = TestBed.createComponent(Type)
+ Object.assign(cmp.componentInstance, inputs)
+ cmp.detectChanges()
+
+ return cmp
+}
+
+export function configureTestingModule(declarations) {
+ TestBed.configureTestingModule({
+ imports: [AbilityModule],
+ declarations,
+ providers: [
+ { provide: PureAbility, useFactory: () => new Ability() }
+ ]
+ })
+}
diff --git a/packages/casl-angular/src/module.ts b/packages/casl-angular/src/AbilityModule.ts
similarity index 60%
rename from packages/casl-angular/src/module.ts
rename to packages/casl-angular/src/AbilityModule.ts
index f1a302a1e..b64c44e03 100644
--- a/packages/casl-angular/src/module.ts
+++ b/packages/casl-angular/src/AbilityModule.ts
@@ -1,14 +1,16 @@
import { NgModule } from '@angular/core';
-import { CanPipe, AblePipe } from './can';
+import { CanPipe, AblePipe, AblePurePipe } from './pipes';
@NgModule({
declarations: [
CanPipe,
AblePipe,
+ AblePurePipe,
],
exports: [
CanPipe,
- AblePipe
+ AblePipe,
+ AblePurePipe,
],
})
export class AbilityModule {
diff --git a/packages/casl-angular/src/index.ts b/packages/casl-angular/src/index.ts
index 8d11884e9..739ed8ad1 100644
--- a/packages/casl-angular/src/index.ts
+++ b/packages/casl-angular/src/index.ts
@@ -1,2 +1,2 @@
-export * from './can';
-export * from './module';
+export * from './pipes';
+export * from './AbilityModule';
diff --git a/packages/casl-angular/src/can.ts b/packages/casl-angular/src/pipes.ts
similarity index 74%
rename from packages/casl-angular/src/can.ts
rename to packages/casl-angular/src/pipes.ts
index 7e37b5cce..9a75e914c 100644
--- a/packages/casl-angular/src/can.ts
+++ b/packages/casl-angular/src/pipes.ts
@@ -1,5 +1,6 @@
import { Pipe, ChangeDetectorRef, Inject, PipeTransform } from '@angular/core';
import { PureAbility, Unsubscribe, AnyAbility } from '@casl/ability';
+import { Observable } from 'rxjs';
class AbilityPipe {
protected _unsubscribeFromAbility?: Unsubscribe;
@@ -25,7 +26,6 @@ class AbilityPipe {
}
}
-// TODO: `pure` can be removed after https://github.com/angular/angular/issues/15041
@Pipe({ name: 'can', pure: false })
export class CanPipe implements PipeTransform {
protected pipe: AbilityPipe;
@@ -63,3 +63,21 @@ export class AblePipe implements PipeTransform {
this.pipe.ngOnDestroy();
}
}
+
+@Pipe({ name: 'ablePure' })
+export class AblePurePipe implements PipeTransform {
+ private _ability: T;
+
+ constructor(@Inject(PureAbility) ability: T) {
+ this._ability = ability;
+ }
+
+ // TODO: `Observable` can be removed after https://github.com/angular/angular/issues/15041
+ transform(...args: Parameters): Observable {
+ return new Observable((s) => {
+ const emit = () => s.next(this._ability.can(...args));
+ emit();
+ return this._ability.on('updated', emit);
+ });
+ }
+}