├── .editorconfig ├── .github └── ISSUE_TEMPLATE │ └── bug_report.md ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── angular.json ├── package.json ├── projects └── ng-observe │ ├── README.md │ ├── jest.config.js │ ├── ng-package.json │ ├── package.json │ ├── setup-jest.ts │ ├── src │ ├── lib │ │ ├── ng-observe.spec.ts │ │ └── ng-observe.ts │ └── public-api.ts │ ├── tsconfig.lib.json │ ├── tsconfig.lib.prod.json │ ├── tsconfig.spec.json │ └── tslint.json ├── tsconfig.json ├── tslint.json └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.ts] 12 | quote_type = single 13 | 14 | [*.md] 15 | max_line_length = off 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Problem description** 11 | A clear and concise description of what the bug is. 12 | 13 | **Reproduction** 14 | A [Stackblitz](http://stackblitz.com/)/[CodeSandbox](http://codesandbox.com/) playground or steps to reproduce the behavior. 15 | 16 | **Screenshots** 17 | If applicable, add screenshots or animated GIFs to help explain your problem. 18 | 19 | **Version information** 20 | - ng-observe: [e.g. 1.0.1] 21 | - Angular: [e.g. 11.2.12] 22 | - RxJS: [e.g. 6.6.7] 23 | 24 | **Additional context** 25 | Add any other context about the problem here. Is it OS or browser-specific? Does it happen only in production or development? 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | # Only exists if Bazel was run 8 | /bazel-out 9 | 10 | # dependencies 11 | /node_modules 12 | 13 | # profiling files 14 | chrome-profiler-events*.json 15 | speed-measure-plugin*.json 16 | 17 | # IDEs and editors 18 | /.idea 19 | .project 20 | .classpath 21 | .c9/ 22 | *.launch 23 | .settings/ 24 | *.sublime-workspace 25 | 26 | # IDE - VSCode 27 | .vscode/* 28 | !.vscode/settings.json 29 | !.vscode/tasks.json 30 | !.vscode/launch.json 31 | !.vscode/extensions.json 32 | .history/* 33 | 34 | # misc 35 | /.sass-cache 36 | /connect.lock 37 | /coverage 38 | /libpeerconnection.log 39 | npm-debug.log 40 | yarn-error.log 41 | testem.log 42 | /typings 43 | 44 | # System Files 45 | .DS_Store 46 | Thumbs.db 47 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "endOfLine": "lf", 4 | "printWidth": 100, 5 | "semi": true, 6 | "singleQuote": true, 7 | "tabWidth": 2, 8 | "trailingComma": "es5" 9 | } 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 NG BOX 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 |
 
3 | ng-observe logo 4 |

5 |

6 | ng-observe 7 |
 
8 |

9 | 10 | Angular reactivity streamlined... 11 | 12 | ### Why? 13 | 14 | - Unlike [AsyncPipe](https://angular.io/api/common/AsyncPipe), you can use it in component classes and even in directives. 15 | - Feels more reactive than unsubscribing on destroy (be it handled by a decorator, triggered by a subject, or done by a direct call in the lifecycle hook). 16 | - Reduces the complexity of working with streams. 17 | - Works in zoneless apps. (v1.1.0+) 18 | 19 | ### How it works 20 | 21 | - Extracts emitted value from observables. 22 | - Marks the component for change detection. 23 | - Leaves no subscription behind. 24 | - Clears old subscriptions and creates new ones at each execution if used in getters, setters or methods. 25 | 26 | ### How to use 27 | 28 | Install the package, and you are good to go. No module import is necessary. 29 | 30 | ``` 31 | npm install ng-observe 32 | ``` 33 | 34 | ...or... 35 | 36 | ``` 37 | yarn add ng-observe 38 | ``` 39 | 40 | ### Example 41 | 42 | We can subscribe to a stream with the `AsyncPipe` in component templates, but we can't use it in component or directive classes. 43 | 44 | ```typescript 45 | @Component({ 46 | template: '{{ fooBar$ | async }}', 47 | }) 48 | class DemoComponent { 49 | foo$ = of('foo'); 50 | 51 | get fooBar$() { 52 | return foo$.pipe(map(val => val + 'bar')); 53 | } 54 | } 55 | ``` 56 | 57 | With ng-observe, we don't need to pipe the stream. 58 | 59 | ```typescript 60 | import { OBSERVE, OBSERVE_PROVIDER, ObserveFn } from 'ng-observe'; 61 | 62 | @Component({ 63 | template: '{{ fooBar }}', 64 | providers: [OBSERVE_PROVIDER], 65 | }) 66 | class DemoComponent { 67 | foo = this.observe(of('foo')); 68 | 69 | get fooBar() { 70 | return this.foo.value + 'bar'; 71 | } 72 | 73 | constructor(@Inject(OBSERVE) private observe: ObserveFn) {} 74 | } 75 | ``` 76 | 77 | You can see other examples at links below: 78 | 79 | - [Basic example](https://stackblitz.com/edit/ng-observe?file=src%2Fapp%2Fapp.ts) 80 | - [Using with Angular router](https://stackblitz.com/edit/ng-observe-router?file=src%2Fapp%2Fapp.ts) 81 | - [Using with NgRx](https://stackblitz.com/edit/ng-observe-ngrx?file=src%2Fapp%2Fapp.ts) 82 | - [Zoneless](https://stackblitz.com/edit/ng-observe-zoneless?file=src%2Fapp%2Fapp.ts) 83 | 84 | > **Important Note:** Do not destructure a collection created by the `ObserveFn`. Otherwise, the reactivity will be lost. Use `toValue` or `toValues` to convert elements of the collection to instances of `Observed` instead. 85 | 86 | You can read [this Medium article](https://ozak.medium.com/angular-reactivity-streamlined-831754b60a11) to learn about what the motivation behind ng-observe is. 87 | 88 | ### API 89 | 90 | #### OBSERVE_PROVIDER 91 | 92 | To use ng-observe in your components and directives, add `OBSERVE_PROVIDER` to providers array in metadata. 93 | 94 | #### ObserveFn 95 | 96 | This function is used to extract a single stream's value. You can inject it via the `OBSERVE` injection token. 97 | 98 | ```typescript 99 | import { OBSERVE, OBSERVE_PROVIDER, ObserveFn } from 'ng-observe'; 100 | 101 | @Component({ 102 | template: '{{ foo.value }}', 103 | providers: [OBSERVE_PROVIDER], 104 | }) 105 | class Component { 106 | foo = this.observe(of('foo')); 107 | 108 | constructor(@Inject(OBSERVE) private observe: ObserveFn) {} 109 | } 110 | ``` 111 | 112 | You can extract multiple streams' value too. 113 | 114 | ```typescript 115 | import { OBSERVE, OBSERVE_PROVIDER, ObserveFn } from 'ng-observe'; 116 | 117 | @Component({ 118 | template: '{{ state.foo }} {{ state.bar }}', 119 | providers: [OBSERVE_PROVIDER], 120 | }) 121 | class Component { 122 | state = this.observe({ foo: of('foo'), bar: of('bar') }); 123 | 124 | constructor(@Inject(OBSERVE) private observe: ObserveFn) {} 125 | } 126 | ``` 127 | 128 | It works with arrays as well. 129 | 130 | ```typescript 131 | import { OBSERVE, OBSERVE_PROVIDER, ObserveFn } from 'ng-observe'; 132 | 133 | @Component({ 134 | template: '{{ state[0] }} {{ state[1] }}', 135 | providers: [OBSERVE_PROVIDER], 136 | }) 137 | class Component { 138 | state = this.observe([of('foo'), of('bar')]); 139 | 140 | constructor(@Inject(OBSERVE) private observe: ObserveFn) {} 141 | } 142 | ``` 143 | 144 | #### ObserveService 145 | 146 | You can call `ObserveService`'s `value` and `collection` methods explicitly instead of `ObserveFn`. This offers a very slight (ignorable in most cases) performance improvement. 147 | 148 | ```typescript 149 | import { ObserveService } from 'ng-observe'; 150 | 151 | @Component({ 152 | template: '{{ foo.value }} {{ state[0] }} {{ state[1] }}', 153 | providers: [ObserveService], 154 | }) 155 | class Component { 156 | foo = this.observe.value(of('foo')); 157 | 158 | state = this.observe.collection([of('foo'), of('bar')]); 159 | 160 | constructor(private observe: ObserveService) {} 161 | } 162 | ``` 163 | 164 | #### Observed 165 | 166 | `ObserveFn` infers types for you, but if you want to assign an observed value later, you can use `Observed` class for type annotation. 167 | 168 | ```typescript 169 | import { OBSERVE, OBSERVE_PROVIDER, Observed } from 'ng-observe'; 170 | 171 | @Component({ 172 | template: '{{ foo.value }}', 173 | providers: [OBSERVE_PROVIDER], 174 | }) 175 | class Component { 176 | foo: Observed; 177 | 178 | constructor(@Inject(OBSERVE) private observe: ObserveFn) { 179 | this.foo = this.observe(of('foo')); 180 | } 181 | } 182 | ``` 183 | 184 | #### toValue 185 | 186 | `toValue` converts an element in the collection to a reactive observed value. Returns an instance of the `Observed` class. 187 | 188 | ```typescript 189 | import { OBSERVE, OBSERVE_PROVIDER, Observed, ObserveFn, toValue } from 'ng-observe'; 190 | 191 | @Component({ 192 | template: '{{ foo.value }} {{ bar.value }}', 193 | providers: [OBSERVE_PROVIDER], 194 | }) 195 | class Component { 196 | foo: Observed; 197 | 198 | bar: Observed; 199 | 200 | constructor(@Inject(OBSERVE) private observe: ObserveFn) { 201 | const state = this.observe({ foo: of('foo'), bar: of('bar') }); 202 | this.foo = toValue(state, 'foo'); 203 | this.bar = toValue(state, 'bar'); 204 | } 205 | } 206 | ``` 207 | 208 | #### toMappedValue 209 | 210 | You can use `toMappedValue` to get a reactive observed value mapped from the collection. Returns an instance of the `Observed` class. 211 | 212 | ```typescript 213 | import { OBSERVE, OBSERVE_PROVIDER, Observed, ObserveFn, toMappedValue } from 'ng-observe'; 214 | 215 | @Component({ 216 | template: '{{ fooBar.value }}', 217 | providers: [OBSERVE_PROVIDER], 218 | }) 219 | class Component { 220 | fooBar: Observed; 221 | 222 | constructor(@Inject(OBSERVE) private observe: ObserveFn) { 223 | const state = this.observe({ foo: of('foo'), bar: of('bar') }); 224 | this.fooBar = toMappedValue(state, ({ foo, bar }) => `${foo} ${bar}`); 225 | } 226 | } 227 | ``` 228 | 229 | #### toValues 230 | 231 | `toValues` converts all elements in collection to reactive observed values. Returns an array/object the indices/keys of which will be the same with the input collection. Each element will be an instance of the `Observed` class. 232 | 233 | ```typescript 234 | import { OBSERVE, OBSERVE_PROVIDER, Observed, ObserveFn, toValues } from 'ng-observe'; 235 | 236 | @Component({ 237 | template: '{{ foo.value }} {{ bar.value }}', 238 | providers: [OBSERVE_PROVIDER], 239 | }) 240 | class Component { 241 | foo: Observed; 242 | 243 | bar: Observed; 244 | 245 | constructor(@Inject(OBSERVE) private observe: ObserveFn) { 246 | const state = this.observe({ foo: of('foo'), bar: of('bar') }); 247 | const { foo, bar } = toValues(state); 248 | this.foo = foo; 249 | this.bar = bar; 250 | } 251 | } 252 | ``` 253 | 254 | #### isCollection 255 | 256 | Collections observed by ng-observe are plain arrays or objects, but you can detect them with `isCollection` function. It returns `true` when input is an observed collection, and `false` when not. 257 | 258 | ```typescript 259 | import { isCollection, OBSERVE, OBSERVE_PROVIDER, ObserveFn } from 'ng-observe'; 260 | 261 | @Component({ 262 | template: '', 263 | providers: [OBSERVE_PROVIDER], 264 | }) 265 | class Component { 266 | constructor(@Inject(OBSERVE) private observe: ObserveFn) { 267 | const state = this.observe({ foo: of('foo'), bar: of('bar') }); 268 | console.log(isCollection(state)); // true 269 | } 270 | } 271 | ``` 272 | 273 | ### Sponsors 274 | 275 | [![volosoft](https://user-images.githubusercontent.com/34455572/115241777-dc7f6680-a129-11eb-8318-4f3c811547e8.png)](https://volosoft.com/) 276 | 277 |
278 | 279 |

280 | Developed by NG Box 281 |

282 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "cli": { 5 | "analytics": false, 6 | "packageManager": "yarn" 7 | }, 8 | "newProjectRoot": "projects", 9 | "projects": { 10 | "ng-observe": { 11 | "projectType": "library", 12 | "root": "projects/ng-observe", 13 | "sourceRoot": "projects/ng-observe/src", 14 | "prefix": "ng-observe", 15 | "architect": { 16 | "build": { 17 | "builder": "@angular-devkit/build-angular:ng-packagr", 18 | "options": { 19 | "tsConfig": "projects/ng-observe/tsconfig.lib.json", 20 | "project": "projects/ng-observe/ng-package.json" 21 | }, 22 | "configurations": { 23 | "production": { 24 | "tsConfig": "projects/ng-observe/tsconfig.lib.prod.json" 25 | } 26 | } 27 | }, 28 | "test": { 29 | "builder": "@angular-builders/jest:run", 30 | "options": { 31 | "no-cache": true 32 | } 33 | }, 34 | "lint": { 35 | "builder": "@angular-devkit/build-angular:tslint", 36 | "options": { 37 | "tsConfig": [ 38 | "projects/ng-observe/tsconfig.lib.json", 39 | "projects/ng-observe/tsconfig.spec.json" 40 | ], 41 | "exclude": ["**/node_modules/**"] 42 | } 43 | } 44 | } 45 | } 46 | }, 47 | "defaultProject": "ng-observe" 48 | } 49 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ng-observe", 3 | "version": "1.1.0", 4 | "scripts": { 5 | "ng": "ng", 6 | "start": "ng serve", 7 | "build": "ng build --prod", 8 | "postbuild": "copyfiles README.md dist/ng-observe", 9 | "test": "ng test", 10 | "lint": "ng lint" 11 | }, 12 | "private": true, 13 | "dependencies": { 14 | "@angular/animations": "~11.2.4", 15 | "@angular/common": "~11.2.4", 16 | "@angular/compiler": "~11.2.4", 17 | "@angular/core": "~11.2.4", 18 | "@angular/forms": "~11.2.4", 19 | "@angular/platform-browser": "~11.2.4", 20 | "@angular/platform-browser-dynamic": "~11.2.4", 21 | "@angular/router": "~11.2.4", 22 | "rxjs": "~6.6.0", 23 | "tslib": "^2.0.0", 24 | "zone.js": "~0.11.3" 25 | }, 26 | "devDependencies": { 27 | "@angular-builders/jest": "^12.0.0", 28 | "@angular-devkit/build-angular": "~0.1102.3", 29 | "@angular/cli": "~11.2.3", 30 | "@angular/compiler-cli": "~11.2.4", 31 | "@types/jest": "^26.0.23", 32 | "@types/node": "^12.11.1", 33 | "codelyzer": "^6.0.0", 34 | "copyfiles": "^2.4.1", 35 | "jest": "^26.6.3", 36 | "karma-coverage": "~2.0.3", 37 | "ng-packagr": "^11.0.0", 38 | "ts-node": "~8.3.0", 39 | "tslint": "~6.1.0", 40 | "typescript": "~4.1.5" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /projects/ng-observe/README.md: -------------------------------------------------------------------------------- 1 | # NgObserve 2 | 3 | This library was generated with [Angular CLI](https://github.com/angular/angular-cli) version 11.2.4. 4 | 5 | ## Code scaffolding 6 | 7 | Run `ng generate component component-name --project ng-observe` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module --project ng-observe`. 8 | > Note: Don't forget to add `--project ng-observe` or else it will be added to the default project in your `angular.json` file. 9 | 10 | ## Build 11 | 12 | Run `ng build ng-observe` to build the project. The build artifacts will be stored in the `dist/` directory. 13 | 14 | ## Publishing 15 | 16 | After building your library with `ng build ng-observe`, go to the dist folder `cd dist/ng-observe` and run `npm publish`. 17 | 18 | ## Running unit tests 19 | 20 | Run `ng test ng-observe` to execute the unit tests via [Karma](https://karma-runner.github.io). 21 | 22 | ## Further help 23 | 24 | To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.io/cli) page. 25 | -------------------------------------------------------------------------------- /projects/ng-observe/jest.config.js: -------------------------------------------------------------------------------- 1 | const { globals } = require('jest-preset-angular/jest-preset.js'); 2 | 3 | module.exports = { 4 | globals, 5 | preset: 'jest-preset-angular', 6 | setupFilesAfterEnv: ['/projects/ng-observe/setup-jest.ts'], 7 | }; 8 | -------------------------------------------------------------------------------- /projects/ng-observe/ng-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", 3 | "dest": "../../dist/ng-observe", 4 | "lib": { 5 | "entryFile": "src/public-api.ts" 6 | } 7 | } -------------------------------------------------------------------------------- /projects/ng-observe/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ng-observe", 3 | "version": "1.1.0", 4 | "description": "Angular reactivity streamlined...", 5 | "keywords": [ 6 | "angular", 7 | "angular2", 8 | "reactivity", 9 | "reactive-programming", 10 | "streams", 11 | "rxjs", 12 | "subscription", 13 | "observer", 14 | "observable", 15 | "utility", 16 | "typescript", 17 | "javascript" 18 | ], 19 | "sideEffects": false, 20 | "repository": { 21 | "type": "git", 22 | "url": "https://github.com/ngbox/ng-observe" 23 | }, 24 | "license": "MIT", 25 | "engines": { 26 | "node": ">=8.5", 27 | "npm": ">=6.0", 28 | "yarn": "^1.0" 29 | }, 30 | "peerDependencies": { 31 | "@angular/core": ">=6.0.0", 32 | "rxjs": ">=6.0.0 || ^5.6.0-forward-compat.4" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /projects/ng-observe/setup-jest.ts: -------------------------------------------------------------------------------- 1 | import 'jest-preset-angular/setup-jest'; 2 | -------------------------------------------------------------------------------- /projects/ng-observe/src/lib/ng-observe.spec.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, Injector } from '@angular/core'; 2 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 3 | import { BehaviorSubject, Observable, of } from 'rxjs'; 4 | import { 5 | isCollection, 6 | OBSERVE, 7 | Observed, 8 | ObserveFn, 9 | ObserveService, 10 | OBSERVE_PROVIDER, 11 | toMappedValue, 12 | toValue, 13 | toValues, 14 | } from './ng-observe'; 15 | 16 | @Component({ 17 | template: `{{ text.value }}`, 18 | changeDetection: ChangeDetectionStrategy.OnPush, 19 | providers: [OBSERVE_PROVIDER], 20 | }) 21 | export class ValueTestComponent { 22 | observe: ObserveFn; 23 | text: Observed; 24 | text$ = new BehaviorSubject('Foo'); 25 | 26 | constructor(public readonly injector: Injector) { 27 | this.observe = injector.get(OBSERVE); 28 | this.text = this.observe(this.text$); 29 | } 30 | 31 | setText(source?: Observable): void { 32 | this.text = this.observe(source || this.text$); 33 | } 34 | } 35 | 36 | describe('Observe Value', () => { 37 | let component: ValueTestComponent; 38 | let fixture: ComponentFixture; 39 | 40 | beforeEach(async () => { 41 | await TestBed.configureTestingModule({ 42 | declarations: [ValueTestComponent], 43 | }).compileComponents(); 44 | }); 45 | 46 | beforeEach(() => { 47 | fixture = TestBed.createComponent(ValueTestComponent); 48 | component = fixture.componentInstance; 49 | fixture.detectChanges(); 50 | }); 51 | 52 | it('should create an observed value', () => { 53 | expect(isCollection(component.text)).toBe(false); 54 | expect(component.text instanceof Observed).toBe(true); 55 | }); 56 | 57 | it('should unwrap observed value', () => { 58 | expect(component.text.value).toBe('Foo'); 59 | expect(fixture.nativeElement.textContent).toBe('Foo'); 60 | }); 61 | 62 | it('should emit new value', () => { 63 | component.text$.next('Qux'); 64 | fixture.detectChanges(); 65 | expect(component.text.value).toBe('Qux'); 66 | expect(fixture.nativeElement.textContent).toBe('Qux'); 67 | }); 68 | 69 | it('should not add multiple destroy hooks on repetitive assignment', () => { 70 | const service = component.injector.get(ObserveService); 71 | expect(service['hooks'].size).toBe(1); 72 | 73 | ['0', '1', '2', '3', '4'].forEach(value => { 74 | component.setText(of(value)); 75 | fixture.detectChanges(); 76 | expect(component.text.value).toBe(value); 77 | expect(fixture.nativeElement.textContent).toBe(value); 78 | }); 79 | 80 | expect(service['hooks'].size).toBe(1); 81 | }); 82 | }); 83 | 84 | @Component({ 85 | template: `{{ state.text }}`, 86 | changeDetection: ChangeDetectionStrategy.OnPush, 87 | providers: [OBSERVE_PROVIDER], 88 | }) 89 | export class CollectionTestComponent { 90 | observe: ObserveFn; 91 | state: { text: string }; 92 | text!: Observed; 93 | firstCharCode!: Observed; 94 | text$ = new BehaviorSubject('Foo'); 95 | values!: { text: Observed }; 96 | 97 | constructor(public readonly injector: Injector) { 98 | this.observe = injector.get(OBSERVE); 99 | this.state = this.observe({ 100 | text: this.text$, 101 | }); 102 | this.setTextAndValues(); 103 | } 104 | 105 | setState(nextState: { text: Observable }): void { 106 | this.state = this.observe(nextState); 107 | this.setTextAndValues(); 108 | } 109 | 110 | private setTextAndValues(): void { 111 | this.values = toValues(this.state); 112 | this.text = toValue(this.state, 'text'); 113 | this.firstCharCode = toMappedValue(this.state, ({ text }) => text.charCodeAt(0)); 114 | } 115 | } 116 | 117 | describe('Observe Collection', () => { 118 | let component: CollectionTestComponent; 119 | let fixture: ComponentFixture; 120 | 121 | beforeEach(async () => { 122 | await TestBed.configureTestingModule({ 123 | declarations: [CollectionTestComponent], 124 | }).compileComponents(); 125 | }); 126 | 127 | beforeEach(() => { 128 | fixture = TestBed.createComponent(CollectionTestComponent); 129 | component = fixture.componentInstance; 130 | fixture.detectChanges(); 131 | }); 132 | 133 | it('should create an observed collection', () => { 134 | expect(isCollection({})).toBe(false); 135 | expect(isCollection([])).toBe(false); 136 | expect(isCollection(component.state)).toBe(true); 137 | expect(component.text instanceof Observed).toBe(true); 138 | expect(component.values.text instanceof Observed).toBe(true); 139 | }); 140 | 141 | it('should unwrap observed value', () => { 142 | expect(component.state.text).toBe('Foo'); 143 | expect(component.text.value).toBe('Foo'); 144 | expect(component.firstCharCode.value).toBe(70); 145 | expect(component.values.text.value).toBe('Foo'); 146 | expect(fixture.nativeElement.textContent).toBe('Foo'); 147 | }); 148 | 149 | it('should emit new value', () => { 150 | component.text$.next('Qux'); 151 | fixture.detectChanges(); 152 | expect(component.state.text).toBe('Qux'); 153 | expect(component.text.value).toBe('Qux'); 154 | expect(component.firstCharCode.value).toBe(81); 155 | expect(component.values.text.value).toBe('Qux'); 156 | expect(fixture.nativeElement.textContent).toBe('Qux'); 157 | }); 158 | 159 | it('should not add multiple destroy hooks on repetitive assignment', () => { 160 | const service = component.injector.get(ObserveService); 161 | expect(service['hooks'].size).toBe(1); 162 | 163 | ['0', '1', '2', '3', '4'].forEach(value => { 164 | component.setState({ text: of(value) }); 165 | fixture.detectChanges(); 166 | expect(component.state.text).toBe(value); 167 | expect(component.text.value).toBe(value); 168 | expect(component.firstCharCode.value).toBe(value.charCodeAt(0)); 169 | expect(component.values.text.value).toBe(value); 170 | expect(fixture.nativeElement.textContent).toBe(value); 171 | }); 172 | 173 | expect(service['hooks'].size).toBe(1); 174 | }); 175 | }); 176 | 177 | // Could not find a way to test zoneless implementation yet 178 | -------------------------------------------------------------------------------- /projects/ng-observe/src/lib/ng-observe.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ChangeDetectorRef, 3 | Inject, 4 | Injectable, 5 | InjectionToken, 6 | NgZone, 7 | OnDestroy, 8 | Optional, 9 | } from '@angular/core'; 10 | import { isObservable, Observable, Subscription } from 'rxjs'; 11 | 12 | export const HASH_FN = new InjectionToken('HASH_FN', { 13 | providedIn: 'root', 14 | factory: createHashFn, 15 | }); 16 | 17 | const BRAND = '__ngObserve__'; 18 | 19 | // @dynamic 20 | @Injectable() 21 | export class ObserveService implements OnDestroy { 22 | private hooks = new Map void>(); 23 | private detectChanges = () => this.cdRef.detectChanges(); 24 | 25 | collection: ObserveCollectionFn = (sources, options = {} as any) => { 26 | const sink: any = Array.isArray(sources) ? [] : {}; 27 | Object.defineProperty(sink, BRAND, { 28 | value: true, 29 | enumerable: false, 30 | writable: false, 31 | }); 32 | 33 | const observe = this.observe(sink); 34 | Object.keys(sources).forEach(key => { 35 | const source: any = sources[key as keyof typeof sources]; 36 | const option: any = options[key as keyof typeof options]; 37 | observe(key, source, option); 38 | }); 39 | 40 | return sink; 41 | }; 42 | 43 | value: ObserveValueFn = ( 44 | source: Observable, 45 | options?: ObserveValueOptions 46 | ): Observed => { 47 | const sink = {}; 48 | 49 | this.observe(sink)('value', source, options); 50 | 51 | return toValue(sink, 'value'); 52 | }; 53 | 54 | constructor( 55 | private cdRef: ChangeDetectorRef, 56 | @Inject(HASH_FN) private hash: HashFn, 57 | @Optional() zone: NgZone 58 | ) { 59 | if (zone instanceof NgZone) { 60 | this.detectChanges = () => this.cdRef.markForCheck(); 61 | } 62 | } 63 | 64 | private createUniqueId(key: string | number | symbol): string { 65 | try { 66 | throw new Error(); 67 | } catch (e) { 68 | return String(this.hash(e.stack + String(key))); 69 | } 70 | } 71 | 72 | private observe(sink: any): Observe { 73 | const fn = ( 74 | key: string | number | symbol, 75 | source: Observable, 76 | { uniqueId = this.createUniqueId(key), errorHandler = () => {} }: ObserveValueOptions = {} 77 | ) => { 78 | let subscription = new Subscription(); 79 | const noop = () => {}; 80 | const unsubscribe = () => subscription.unsubscribe(); 81 | const complete = () => { 82 | (this.hooks.get(uniqueId) || noop)(); 83 | this.hooks.delete(uniqueId); 84 | }; 85 | 86 | complete(); 87 | this.hooks.set(uniqueId, unsubscribe); 88 | 89 | // tslint:disable-next-line: deprecation 90 | subscription = source.subscribe({ 91 | next: x => { 92 | sink[key] = x; 93 | this.detectChanges(); 94 | }, 95 | error: errorHandler, 96 | complete, 97 | }); 98 | }; 99 | 100 | return fn; 101 | } 102 | 103 | ngOnDestroy(): void { 104 | this.hooks.forEach(unsubscribe => unsubscribe()); 105 | } 106 | } 107 | 108 | export const OBSERVE = new InjectionToken('OBSERVE'); 109 | 110 | export const OBSERVE_PROVIDER = [ 111 | ObserveService, 112 | { 113 | provide: OBSERVE, 114 | useFactory: observeFactory, 115 | deps: [ObserveService], 116 | }, 117 | ]; 118 | 119 | export function observeFactory(service: ObserveService): ObserveFn { 120 | return ( 121 | source: Observable | ObservableCollection, 122 | options?: ObserveValueOptions | ObserveCollectionOptions 123 | ) => 124 | isObservable(source) 125 | ? service.value(source, options as ObserveValueOptions) 126 | : service.collection(source, options as ObserveCollectionOptions); 127 | } 128 | 129 | type ObserveCollectionFn = ( 130 | source: ObservableCollection, 131 | options?: ObserveCollectionOptions 132 | ) => Collection; 133 | 134 | type ObserveValueFn = ( 135 | source: Observable, 136 | options?: ObserveValueOptions 137 | ) => Observed; 138 | 139 | export type ObserveFn = | ObservableCollection>( 140 | source: Source, 141 | options?: ObserveFnOptions 142 | ) => ObserveFnReturnValue; 143 | 144 | type Observe = ( 145 | key: string | number | symbol, 146 | source: Observable, 147 | options?: ObserveValueOptions 148 | ) => void; 149 | 150 | export type ObservableCollection = Collection extends Array 151 | ? Array> 152 | : { [Key in keyof Collection]: Observable }; 153 | 154 | export type ObserveCollectionOptions = Collection extends Array 155 | ? Array 156 | : { [Key in keyof Collection]?: ObserveValueOptions }; 157 | 158 | export type ObservedValues = Collection extends Array 159 | ? Array> 160 | : { [Key in keyof Collection]: Observed }; 161 | 162 | export interface ObserveValueOptions { 163 | errorHandler?: (err: any) => void; 164 | uniqueId?: string; 165 | } 166 | 167 | export type ObserveFnOptions = Source extends Observable 168 | ? ObserveValueOptions 169 | : Source extends ObservableCollection 170 | ? ObserveCollectionOptions 171 | : never; 172 | 173 | export type ObserveFnReturnValue = Source extends Observable 174 | ? Observed 175 | : Source extends ObservableCollection 176 | ? Collection 177 | : never; 178 | 179 | export class Observed { 180 | private readonly getter: () => Value; 181 | 182 | constructor(private readonly seed: Seed, mapFn: (source: typeof seed) => Value) { 183 | this.getter = () => mapFn(this.seed); 184 | } 185 | 186 | get value(): Value { 187 | return this.getter(); 188 | } 189 | } 190 | 191 | export type HashFn = (input: string) => number; 192 | 193 | export function createHashFn(): HashFn { 194 | const k = 2654435761; 195 | const shift = Math.imul ? (n: number) => Math.imul(n, k) : (n: number) => imul(n, k); 196 | 197 | const hashFn = (input: string) => { 198 | let index = input.length; 199 | let hash = 0xabadcafe; 200 | 201 | while (index--) { 202 | hash = shift(hash ^ input.charCodeAt(index)); 203 | } 204 | 205 | return (hash ^ (hash >>> 16)) >>> 0; 206 | }; 207 | 208 | return hashFn; 209 | } 210 | 211 | // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/imul 212 | export function imul(a: number, b: number): number { 213 | b |= 0; 214 | 215 | let result = (a & 0x003fffff) * b; 216 | if (a & 0xffc00000) { 217 | result += ((a & 0xffc00000) * b) | 0; 218 | } 219 | 220 | return result | 0; 221 | } 222 | 223 | export function isCollection(source: any): boolean { 224 | return Boolean(source && source[BRAND]); 225 | } 226 | 227 | export function toMappedValue | Record>( 228 | collection: Seed, 229 | mapFn: (source: typeof collection) => Value 230 | ): Observed { 231 | return new Observed(collection as any, mapFn); 232 | } 233 | 234 | export function toValue(collection: Array, key: number): Observed; 235 | export function toValue(collection: Record, key: string): Observed; 236 | export function toValue( 237 | collection: Array | Record, 238 | key: number | string 239 | ): Observed { 240 | return new Observed(collection as any, source => source[key]); 241 | } 242 | 243 | export function toValues( 244 | collection: Collection 245 | ): ObservedValues; 246 | export function toValues>( 247 | collection: Collection 248 | ): ObservedValues; 249 | export function toValues( 250 | collection: Value[] | Record 251 | ): ObservedValues> { 252 | if (Array.isArray(collection)) { 253 | return collection.map((_, index) => new Observed(collection, source => source[index])); 254 | } 255 | 256 | const values: Record> = {}; 257 | 258 | for (const key in collection) { 259 | if (collection.hasOwnProperty(key)) { 260 | values[key] = new Observed(collection, source => source[key]); 261 | } 262 | } 263 | 264 | return values; 265 | } 266 | -------------------------------------------------------------------------------- /projects/ng-observe/src/public-api.ts: -------------------------------------------------------------------------------- 1 | export * from './lib/ng-observe'; 2 | -------------------------------------------------------------------------------- /projects/ng-observe/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "../../tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "../../out-tsc/lib", 6 | "target": "es2015", 7 | "declaration": true, 8 | "declarationMap": true, 9 | "inlineSources": true, 10 | "types": [], 11 | "lib": ["dom", "es2018"] 12 | }, 13 | "angularCompilerOptions": { 14 | "skipTemplateCodegen": true, 15 | "strictMetadataEmit": true, 16 | "enableResourceInlining": true 17 | }, 18 | "exclude": ["**/*.spec.ts"] 19 | } 20 | -------------------------------------------------------------------------------- /projects/ng-observe/tsconfig.lib.prod.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.lib.json", 4 | "compilerOptions": { 5 | "declarationMap": false 6 | }, 7 | "angularCompilerOptions": { 8 | "enableIvy": false 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /projects/ng-observe/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "../../tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "../../out-tsc/spec", 6 | "types": ["jest"], 7 | "emitDecoratorMetadata": true 8 | }, 9 | "include": ["**/*.spec.ts", "**/*.d.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /projects/ng-observe/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tslint.json", 3 | "rules": { 4 | "directive-selector": [ 5 | true, 6 | "attribute", 7 | "ngObserve", 8 | "camelCase" 9 | ], 10 | "component-selector": [ 11 | true, 12 | "element", 13 | "ng-observe", 14 | "kebab-case" 15 | ] 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "compileOnSave": false, 4 | "compilerOptions": { 5 | "baseUrl": "./", 6 | "outDir": "./dist/out-tsc", 7 | "forceConsistentCasingInFileNames": true, 8 | "strict": true, 9 | "noImplicitReturns": true, 10 | "noFallthroughCasesInSwitch": true, 11 | "sourceMap": true, 12 | "declaration": false, 13 | "downlevelIteration": true, 14 | "paths": { 15 | "ng-observe": ["projects/ng-observe/src/public-api.ts"] 16 | }, 17 | "experimentalDecorators": true, 18 | "moduleResolution": "node", 19 | "importHelpers": true, 20 | "target": "es2015", 21 | "module": "es2020", 22 | "lib": ["es2018", "dom"], 23 | "types": ["jest"] 24 | }, 25 | "angularCompilerOptions": { 26 | "enableI18nLegacyMessageIdFormat": false, 27 | "strictInjectionParameters": true, 28 | "strictInputAccessModifiers": true, 29 | "strictTemplates": true 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tslint:recommended", 3 | "rulesDirectory": ["codelyzer"], 4 | "rules": { 5 | "align": { 6 | "options": ["parameters", "statements"] 7 | }, 8 | "array-type": false, 9 | "arrow-return-shorthand": true, 10 | "curly": true, 11 | "deprecation": { 12 | "severity": "warning" 13 | }, 14 | "eofline": true, 15 | "import-blacklist": [true, "rxjs/Rx"], 16 | "import-spacing": true, 17 | "indent": { 18 | "options": ["spaces"] 19 | }, 20 | "max-classes-per-file": false, 21 | "max-line-length": [true, 100], 22 | "member-ordering": [ 23 | true, 24 | { 25 | "order": ["static-field", "instance-field", "static-method", "instance-method"] 26 | } 27 | ], 28 | "no-bitwise": false, 29 | "no-console": [true, "debug", "info", "time", "timeEnd", "trace"], 30 | "no-empty": false, 31 | "no-inferrable-types": [true, "ignore-params"], 32 | "no-non-null-assertion": true, 33 | "no-redundant-jsdoc": true, 34 | "no-switch-case-fall-through": true, 35 | "no-var-requires": false, 36 | "object-literal-key-quotes": [true, "as-needed"], 37 | "quotemark": [true, "single"], 38 | "space-before-function-paren": { 39 | "options": { 40 | "anonymous": "always", 41 | "asyncArrow": "always", 42 | "constructor": "never", 43 | "method": "never", 44 | "named": "never" 45 | } 46 | }, 47 | "typedef": [true, "call-signature"], 48 | "typedef-whitespace": { 49 | "options": [ 50 | { 51 | "call-signature": "nospace", 52 | "index-signature": "nospace", 53 | "parameter": "nospace", 54 | "property-declaration": "nospace", 55 | "variable-declaration": "nospace" 56 | }, 57 | { 58 | "call-signature": "onespace", 59 | "index-signature": "onespace", 60 | "parameter": "onespace", 61 | "property-declaration": "onespace", 62 | "variable-declaration": "onespace" 63 | } 64 | ] 65 | }, 66 | "variable-name": { 67 | "options": ["ban-keywords", "check-format", "allow-pascal-case"] 68 | }, 69 | "whitespace": { 70 | "options": [ 71 | "check-branch", 72 | "check-decl", 73 | "check-operator", 74 | "check-separator", 75 | "check-type", 76 | "check-typecast" 77 | ] 78 | }, 79 | "component-class-suffix": true, 80 | "contextual-lifecycle": true, 81 | "directive-class-suffix": true, 82 | "no-angle-bracket-type-assertion": false, 83 | "no-conflicting-lifecycle": true, 84 | "no-host-metadata-property": true, 85 | "no-input-rename": true, 86 | "no-inputs-metadata-property": true, 87 | "no-output-native": true, 88 | "no-output-on-prefix": true, 89 | "no-output-rename": true, 90 | "no-outputs-metadata-property": true, 91 | "no-string-literal": false, 92 | "template-banana-in-box": true, 93 | "template-no-negated-async": true, 94 | "use-lifecycle-interface": true, 95 | "use-pipe-transform-interface": true 96 | } 97 | } 98 | --------------------------------------------------------------------------------