├── .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 |
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 | [](https://volosoft.com/)
276 |
277 |
278 |
279 |
280 |
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 |
--------------------------------------------------------------------------------