├── .editorconfig ├── .gitignore ├── README.md ├── angular.json ├── browserslist ├── e2e ├── protractor.conf.js ├── src │ ├── app.e2e-spec.ts │ └── app.po.ts └── tsconfig.json ├── karma.conf.js ├── package-lock.json ├── package.json ├── projects └── ng-reactive │ ├── README.md │ ├── karma.conf.js │ ├── ng-package.json │ ├── package.json │ ├── src │ ├── lib │ │ ├── metadata.ts │ │ ├── ng-reactive.spec.ts │ │ ├── ng-reactive.ts │ │ └── util.ts │ ├── public-api.ts │ └── test.ts │ ├── tsconfig.lib.json │ ├── tsconfig.spec.json │ └── tslint.json ├── src ├── app │ ├── app.component.css │ ├── app.component.html │ ├── app.component.spec.ts │ ├── app.component.ts │ └── app.module.ts ├── assets │ └── .gitkeep ├── environments │ ├── environment.prod.ts │ └── environment.ts ├── favicon.ico ├── index.html ├── main.ts ├── polyfills.ts ├── styles.css └── test.ts ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.spec.json └── tslint.json /.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 | [*.md] 12 | max_line_length = off 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ng-reactive 2 | 3 | Reactive utility for Angular, embraces binding from RxJS observable to component property. 4 | 5 | ## Install 6 | 7 | ```bash 8 | npm install ng-reactive 9 | ``` 10 | 11 | ## Usage 12 | 13 | Eliminate async pipe and subscription process: 14 | 15 | ```typescript 16 | import { bind, reset, state, unbind, updateOn, viewUpdate, Reactive, StateChanges } from 'ng-reactive' 17 | import { interval, of, Observable } from 'rxjs' 18 | import { map } from 'rxjs/operators' 19 | import { AccountService, User } from './account.service' 20 | import { HeartbeatService } from './heartbeat.service' 21 | 22 | @Component({ 23 | template: ` 24 | 25 |

Inside reactive component

26 |

Hello {{ username }}, it's {{ time | date:'medium' }} now.

27 | 28 |

Items:

29 | 32 |
33 | `, 34 | }) 35 | class HelloComponent extends Reactive { 36 | // Define inputs as before 37 | @Input() id: string 38 | @Input() password: string 39 | 40 | // Define a set of reactive states with their default values 41 | username = state('Anonymous') 42 | items: string[] = state([]) 43 | time = state(new Date()) 44 | 45 | constructor( 46 | injector: Injector, 47 | private accountService: AccountService, 48 | private heartbeatService: HeartbeatService, 49 | ) { 50 | // Passing injector for automatic marking dirty 51 | super(injector) 52 | } 53 | 54 | // Implement `update()` method based on changes and whether it's first run 55 | update(changes: StateChanges, first: boolean) { 56 | // Execute only in first run 57 | if (first) { 58 | // Bind an RxJS observable to a reactive state 59 | bind(this.time, interval(1000).pipe(map(() => new Date()))) 60 | } 61 | 62 | // Execute whenever the `id` or `password` input changes 63 | if (updateOn(changes.id, changes.password)) { 64 | if (this.id != null && this.password != null) { 65 | // Binding to a constant value 66 | bind(this.username, of('Loading...')) 67 | 68 | const user$ = this.doLogin(this.id, this.password) 69 | // Previous subscription will be unsubscribed automatically 70 | bind(this.username, user$.pipe(map(x => x.name))) 71 | bind(this.items, user$.pipe(map(x => x.items))) 72 | } else { 73 | // Unbind a reactive state 74 | unbind(this.username) 75 | unbind(this.items) 76 | 77 | // Reset a reactive state to its default value 78 | reset(this.username) 79 | // Imperatively change a reactive state 80 | // Array is mutable and the default value may have been polluted 81 | this.items = [] 82 | } 83 | } 84 | 85 | // Execute whenever the `username` reactive state changes 86 | // Both inputs and reactive states are tracked 87 | if (updateOn(changes.username)) { 88 | // Schedule an operation after view updated 89 | viewUpdate(() => { 90 | // Operation depends on DOM 91 | this.someViewOperation() 92 | }) 93 | } 94 | 95 | // Execute whenever the `time` reactive state changes or in the first run 96 | if (updateOn(changes.time) || first) { 97 | this.heartbeatService.send() 98 | } 99 | } 100 | 101 | // Example method for observable handling 102 | private doLogin(id: string, password: string): Observable { 103 | return this.accountService.login(this.id, this.password) 104 | } 105 | 106 | // Example method for side effects 107 | private someViewOperation() { 108 | const $greeting = document.querySelector('#greeting') 109 | const result = $greeting.textContent.indexOf(this.username) > 0 110 | if (result) { 111 | console.log(`View operation done`) 112 | } else { 113 | console.warn(`View operation failed`) 114 | } 115 | } 116 | } 117 | ``` 118 | 119 | Live example available at [StackBlitz](https://stackblitz.com/edit/angular-gtufmp?file=src%2Fapp%2Fhello.component.ts). 120 | 121 | ## Legacy Mode 122 | 123 | If someone don't want or cannot use base class, then it can also be combined with plain Angular components: 124 | 125 | ```typescript 126 | import { bind, state, unbind, updateOn, viewUpdate, Reactive, StateChanges } from 'ng-reactive' 127 | 128 | @Component() 129 | class HelloComponent { 130 | @Input() foo: string 131 | 132 | bar = state(0) 133 | baz = state(true) 134 | 135 | constructor(private injector: Injector) {} 136 | 137 | ngOnChanges(changes: SimpleChanges) { 138 | if (changes.foo) { 139 | // Use `bind()` or `unbind()` at any time 140 | bind(this.bar, this.makeUseOf(this.foo)) 141 | } 142 | } 143 | 144 | ngOnInit() { 145 | // Remember to call `init()` in `OnInit` hook 146 | init(this, this.injector) 147 | 148 | bind(this.baz, someDataSource$) 149 | } 150 | 151 | ngOnDestroy() { 152 | // Remember to call `deinit()` in `OnDestroy` hook 153 | deinit(this) 154 | } 155 | 156 | private makeUseOf(foo: string): Observable { 157 | // ... 158 | } 159 | } 160 | ``` 161 | 162 | Note, `updateOn()` and `viewUpdate()` cannot be used without the base class. 163 | 164 | ## Cleanup 165 | 166 | An observable is self-disposable, just make sure the finalization exists when making that data source. 167 | 168 | ```typescript 169 | // Setup builtin cleanup logic 170 | const dataSource = new Observable((observable) => { 171 | // ... 172 | return () => { 173 | additionalCleanupLogic() 174 | } 175 | }) 176 | 177 | // Setup extra cleanup logic 178 | const dataSource = someObservable.pipe( 179 | finalize(() => { 180 | additionalCleanupLogic() 181 | }) 182 | ) 183 | ``` 184 | 185 | ## Caveat 186 | 187 | The `OnChanges` hook in Angular uses non-minified property name, and the `StateChanges` object bases on it. 188 | When using Closure compiler advanced mode or other property-mangling tool, the input names need to be literal but reactive state names need to be identifier, like: 189 | 190 | ```typescript 191 | update(changes: StateChanges, first: boolean) { 192 | if (updateOn(changes['someInput'])) { 193 | // ... 194 | } 195 | 196 | if (updateOn(changes.someState)) { 197 | // ... 198 | } 199 | } 200 | ``` 201 | 202 | Also need to make sure input name are not too short (which could conflict with other minified names). 203 | 204 | Hopefully not much people are doing property mangling outside Google. 205 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "ng-reactive-workspace": { 7 | "projectType": "application", 8 | "schematics": {}, 9 | "root": "", 10 | "sourceRoot": "src", 11 | "prefix": "app", 12 | "architect": { 13 | "build": { 14 | "builder": "@angular-devkit/build-angular:browser", 15 | "options": { 16 | "outputPath": "dist/ng-reactive-workspace", 17 | "index": "src/index.html", 18 | "main": "src/main.ts", 19 | "polyfills": "src/polyfills.ts", 20 | "tsConfig": "tsconfig.app.json", 21 | "aot": false, 22 | "assets": [ 23 | "src/favicon.ico", 24 | "src/assets" 25 | ], 26 | "styles": [ 27 | "src/styles.css" 28 | ], 29 | "scripts": [] 30 | }, 31 | "configurations": { 32 | "production": { 33 | "fileReplacements": [ 34 | { 35 | "replace": "src/environments/environment.ts", 36 | "with": "src/environments/environment.prod.ts" 37 | } 38 | ], 39 | "optimization": true, 40 | "outputHashing": "all", 41 | "sourceMap": false, 42 | "extractCss": true, 43 | "namedChunks": false, 44 | "aot": true, 45 | "extractLicenses": true, 46 | "vendorChunk": false, 47 | "buildOptimizer": true, 48 | "budgets": [ 49 | { 50 | "type": "initial", 51 | "maximumWarning": "2mb", 52 | "maximumError": "5mb" 53 | } 54 | ] 55 | } 56 | } 57 | }, 58 | "serve": { 59 | "builder": "@angular-devkit/build-angular:dev-server", 60 | "options": { 61 | "browserTarget": "ng-reactive-workspace:build" 62 | }, 63 | "configurations": { 64 | "production": { 65 | "browserTarget": "ng-reactive-workspace:build:production" 66 | } 67 | } 68 | }, 69 | "extract-i18n": { 70 | "builder": "@angular-devkit/build-angular:extract-i18n", 71 | "options": { 72 | "browserTarget": "ng-reactive-workspace:build" 73 | } 74 | }, 75 | "test": { 76 | "builder": "@angular-devkit/build-angular:karma", 77 | "options": { 78 | "main": "src/test.ts", 79 | "polyfills": "src/polyfills.ts", 80 | "tsConfig": "tsconfig.spec.json", 81 | "karmaConfig": "karma.conf.js", 82 | "assets": [ 83 | "src/favicon.ico", 84 | "src/assets" 85 | ], 86 | "styles": [ 87 | "src/styles.css" 88 | ], 89 | "scripts": [] 90 | } 91 | }, 92 | "lint": { 93 | "builder": "@angular-devkit/build-angular:tslint", 94 | "options": { 95 | "tsConfig": [ 96 | "tsconfig.app.json", 97 | "tsconfig.spec.json", 98 | "e2e/tsconfig.json" 99 | ], 100 | "exclude": [ 101 | "**/node_modules/**" 102 | ] 103 | } 104 | }, 105 | "e2e": { 106 | "builder": "@angular-devkit/build-angular:protractor", 107 | "options": { 108 | "protractorConfig": "e2e/protractor.conf.js", 109 | "devServerTarget": "ng-reactive-workspace:serve" 110 | }, 111 | "configurations": { 112 | "production": { 113 | "devServerTarget": "ng-reactive-workspace:serve:production" 114 | } 115 | } 116 | } 117 | } 118 | }, 119 | "ng-reactive": { 120 | "projectType": "library", 121 | "root": "projects/ng-reactive", 122 | "sourceRoot": "projects/ng-reactive/src", 123 | "prefix": "lib", 124 | "architect": { 125 | "build": { 126 | "builder": "@angular-devkit/build-ng-packagr:build", 127 | "options": { 128 | "tsConfig": "projects/ng-reactive/tsconfig.lib.json", 129 | "project": "projects/ng-reactive/ng-package.json" 130 | } 131 | }, 132 | "test": { 133 | "builder": "@angular-devkit/build-angular:karma", 134 | "options": { 135 | "main": "projects/ng-reactive/src/test.ts", 136 | "tsConfig": "projects/ng-reactive/tsconfig.spec.json", 137 | "karmaConfig": "projects/ng-reactive/karma.conf.js" 138 | } 139 | }, 140 | "lint": { 141 | "builder": "@angular-devkit/build-angular:tslint", 142 | "options": { 143 | "tsConfig": [ 144 | "projects/ng-reactive/tsconfig.lib.json", 145 | "projects/ng-reactive/tsconfig.spec.json" 146 | ], 147 | "exclude": [ 148 | "**/node_modules/**" 149 | ] 150 | } 151 | } 152 | } 153 | }}, 154 | "defaultProject": "ng-reactive-workspace" 155 | } 156 | -------------------------------------------------------------------------------- /browserslist: -------------------------------------------------------------------------------- 1 | # This file is used by the build system to adjust CSS and JS output to support the specified browsers below. 2 | # For additional information regarding the format and rule options, please see: 3 | # https://github.com/browserslist/browserslist#queries 4 | 5 | # You can see what browsers were selected by your queries by running: 6 | # npx browserslist 7 | 8 | > 0.5% 9 | last 2 versions 10 | Firefox ESR 11 | not dead 12 | not IE 9-11 # For IE 9-11 support, remove 'not'. -------------------------------------------------------------------------------- /e2e/protractor.conf.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | // Protractor configuration file, see link for more information 3 | // https://github.com/angular/protractor/blob/master/lib/config.ts 4 | 5 | const { SpecReporter } = require('jasmine-spec-reporter'); 6 | 7 | /** 8 | * @type { import("protractor").Config } 9 | */ 10 | exports.config = { 11 | allScriptsTimeout: 11000, 12 | specs: [ 13 | './src/**/*.e2e-spec.ts' 14 | ], 15 | capabilities: { 16 | 'browserName': 'chrome' 17 | }, 18 | directConnect: true, 19 | baseUrl: 'http://localhost:4200/', 20 | framework: 'jasmine', 21 | jasmineNodeOpts: { 22 | showColors: true, 23 | defaultTimeoutInterval: 30000, 24 | print: function() {} 25 | }, 26 | onPrepare() { 27 | require('ts-node').register({ 28 | project: require('path').join(__dirname, './tsconfig.json') 29 | }); 30 | jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } })); 31 | } 32 | }; -------------------------------------------------------------------------------- /e2e/src/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { browser, logging } from 'protractor' 2 | import { AppPage } from './app.po' 3 | 4 | describe('workspace-project App', () => { 5 | let page: AppPage 6 | 7 | beforeEach(() => { 8 | page = new AppPage() 9 | }) 10 | 11 | it('should display welcome message', () => { 12 | page.navigateTo() 13 | expect(page.getTitleText()).toEqual('Welcome to ng-reactive!') 14 | }) 15 | 16 | afterEach(async () => { 17 | // Assert that there are no errors emitted from the browser 18 | const logs = await browser.manage().logs().get(logging.Type.BROWSER) 19 | expect(logs).not.toContain(jasmine.objectContaining({ 20 | level: logging.Level.SEVERE, 21 | } as logging.Entry)) 22 | }) 23 | }) 24 | -------------------------------------------------------------------------------- /e2e/src/app.po.ts: -------------------------------------------------------------------------------- 1 | import { browser, by, element } from 'protractor' 2 | 3 | export class AppPage { 4 | navigateTo() { 5 | return browser.get(browser.baseUrl) as Promise 6 | } 7 | 8 | getTitleText() { 9 | return element(by.css('app-root h1')).getText() as Promise 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /e2e/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/e2e", 5 | "module": "commonjs", 6 | "target": "es5", 7 | "types": [ 8 | "jasmine", 9 | "jasminewd2", 10 | "node" 11 | ] 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration file, see link for more information 2 | // https://karma-runner.github.io/1.0/config/configuration-file.html 3 | 4 | module.exports = function (config) { 5 | config.set({ 6 | basePath: '', 7 | frameworks: ['jasmine', '@angular-devkit/build-angular'], 8 | plugins: [ 9 | require('karma-jasmine'), 10 | require('karma-chrome-launcher'), 11 | require('karma-jasmine-html-reporter'), 12 | require('karma-coverage-istanbul-reporter'), 13 | require('@angular-devkit/build-angular/plugins/karma') 14 | ], 15 | client: { 16 | clearContext: false // leave Jasmine Spec Runner output visible in browser 17 | }, 18 | coverageIstanbulReporter: { 19 | dir: require('path').join(__dirname, './coverage/ng-reactive'), 20 | reports: ['html', 'lcovonly', 'text-summary'], 21 | fixWebpackSourcePaths: true 22 | }, 23 | reporters: ['progress', 'kjhtml'], 24 | port: 9876, 25 | colors: true, 26 | logLevel: config.LOG_INFO, 27 | autoWatch: true, 28 | browsers: ['Chrome'], 29 | singleRun: false, 30 | restartOnFileChange: true 31 | }) 32 | } 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ng-reactive", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "ng": "ng", 6 | "start": "ng serve", 7 | "build": "ng build", 8 | "test": "ng test", 9 | "lint": "ng lint", 10 | "e2e": "ng e2e" 11 | }, 12 | "private": true, 13 | "dependencies": { 14 | "@angular/animations": "~8.1.1", 15 | "@angular/common": "~8.1.1", 16 | "@angular/compiler": "~8.1.1", 17 | "@angular/core": "~8.1.1", 18 | "@angular/forms": "~8.1.1", 19 | "@angular/platform-browser": "~8.1.1", 20 | "@angular/platform-browser-dynamic": "~8.1.1", 21 | "@angular/router": "~8.1.1", 22 | "rxjs": "~6.4.0", 23 | "tslib": "^1.9.0", 24 | "zone.js": "~0.9.1" 25 | }, 26 | "devDependencies": { 27 | "@angular-devkit/build-angular": "~0.801.1", 28 | "@angular-devkit/build-ng-packagr": "~0.801.1", 29 | "@angular/cli": "~8.1.1", 30 | "@angular/compiler-cli": "~8.1.1", 31 | "@angular/language-service": "~8.1.1", 32 | "@types/node": "~8.9.4", 33 | "@types/jasmine": "~3.3.8", 34 | "@types/jasminewd2": "~2.0.3", 35 | "codelyzer": "^5.0.0", 36 | "jasmine-core": "~3.4.0", 37 | "jasmine-spec-reporter": "~4.2.1", 38 | "karma": "~4.1.0", 39 | "karma-chrome-launcher": "~2.2.0", 40 | "karma-coverage-istanbul-reporter": "~2.0.1", 41 | "karma-jasmine": "~2.0.1", 42 | "karma-jasmine-html-reporter": "^1.4.0", 43 | "ng-packagr": "^5.1.0", 44 | "protractor": "~5.4.0", 45 | "ts-node": "~7.0.0", 46 | "tsickle": "^0.35.0", 47 | "tslint": "~5.15.0", 48 | "typescript": "~3.4.3" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /projects/ng-reactive/README.md: -------------------------------------------------------------------------------- 1 | # ng-reactive 2 | 3 | See GitHub repo page for more information. 4 | -------------------------------------------------------------------------------- /projects/ng-reactive/karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration file, see link for more information 2 | // https://karma-runner.github.io/1.0/config/configuration-file.html 3 | 4 | module.exports = function (config) { 5 | config.set({ 6 | basePath: '', 7 | frameworks: ['jasmine', '@angular-devkit/build-angular'], 8 | plugins: [ 9 | require('karma-jasmine'), 10 | require('karma-chrome-launcher'), 11 | require('karma-jasmine-html-reporter'), 12 | require('karma-coverage-istanbul-reporter'), 13 | require('@angular-devkit/build-angular/plugins/karma') 14 | ], 15 | client: { 16 | clearContext: false // leave Jasmine Spec Runner output visible in browser 17 | }, 18 | coverageIstanbulReporter: { 19 | dir: require('path').join(__dirname, '../../coverage/ng-reactive'), 20 | reports: ['html', 'lcovonly', 'text-summary'], 21 | fixWebpackSourcePaths: true 22 | }, 23 | reporters: ['progress', 'kjhtml'], 24 | port: 9876, 25 | colors: true, 26 | logLevel: config.LOG_INFO, 27 | autoWatch: true, 28 | browsers: ['Chrome'], 29 | singleRun: false, 30 | restartOnFileChange: true 31 | }) 32 | } 33 | -------------------------------------------------------------------------------- /projects/ng-reactive/ng-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", 3 | "dest": "../../dist/ng-reactive", 4 | "lib": { 5 | "entryFile": "src/public-api.ts" 6 | } 7 | } -------------------------------------------------------------------------------- /projects/ng-reactive/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ng-reactive", 3 | "version": "0.0.1", 4 | "repository": { 5 | "type": "git", 6 | "url": "https://github.com/trotyl/ng-reactive.git" 7 | }, 8 | "peerDependencies": { 9 | "@angular/core": "^8.0.0", 10 | "rxjs": "^6.0.0" 11 | } 12 | } -------------------------------------------------------------------------------- /projects/ng-reactive/src/lib/metadata.ts: -------------------------------------------------------------------------------- 1 | import { Subscription } from 'rxjs' 2 | 3 | export const enum InstanceMetaKey { 4 | patched, 5 | hasPendingChange, 6 | properties, 7 | } 8 | 9 | export interface InstanceMeta extends Array { 10 | [InstanceMetaKey.patched]: boolean 11 | [InstanceMetaKey.hasPendingChange]: boolean 12 | [InstanceMetaKey.properties]: { [property: string]: InstancePropertyMeta } 13 | } 14 | 15 | export const enum ProtoMetaKey { 16 | patched, 17 | properties, 18 | } 19 | 20 | export interface ProtoMeta extends Array { 21 | [ProtoMetaKey.patched]: boolean 22 | [ProtoMetaKey.properties]: { [property: string]: ProtoPropertyMeta } 23 | } 24 | 25 | export const enum InstancePropertyMetaKey { 26 | defaultValue, 27 | currentValue, 28 | previousValue, 29 | hasPendingChange, 30 | changesCount, 31 | subscription, 32 | } 33 | 34 | export interface InstancePropertyMeta extends Array { 35 | [InstancePropertyMetaKey.defaultValue]: T 36 | [InstancePropertyMetaKey.currentValue]: T 37 | [InstancePropertyMetaKey.previousValue]: T | null 38 | [InstancePropertyMetaKey.hasPendingChange]: boolean 39 | [InstancePropertyMetaKey.changesCount]: number 40 | [InstancePropertyMetaKey.subscription]: Subscription | null 41 | } 42 | 43 | export const enum ProtoPropertyMetaKey { 44 | field = 0, 45 | } 46 | 47 | export interface ProtoPropertyMeta extends Array { 48 | [ProtoPropertyMetaKey.field]: Field 49 | } 50 | 51 | type Field = WeakMap 52 | 53 | 54 | export const instanceRecords = new WeakMap() 55 | export const protoRecords = new WeakMap() 56 | -------------------------------------------------------------------------------- /projects/ng-reactive/src/lib/ng-reactive.spec.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable:no-use-before-declare 2 | 3 | import { ChangeDetectionStrategy, Component, Injector } from '@angular/core' 4 | import { async, ComponentFixture, TestBed } from '@angular/core/testing' 5 | import { By } from '@angular/platform-browser' 6 | import { Subject } from 'rxjs' 7 | import { map } from 'rxjs/operators' 8 | import { bind, deinit, init, reset, state, updateOn, Reactive, StateChanges } from './ng-reactive' 9 | 10 | describe('without base class', () => { 11 | @Component({ 12 | template: `{{foo}}-{{bar}}-{{baz}}`, 13 | }) 14 | class TestComponent { 15 | foo = state(0) 16 | bar = state(1) 17 | baz = state(2) 18 | 19 | constructor(private injector: Injector) { 20 | bind(this.baz, source.pipe(map(x => x + 1))) 21 | } 22 | 23 | ngOnInit() { 24 | init(this, this.injector) 25 | 26 | bind(this.bar, source) 27 | } 28 | 29 | ngOnDestroy() { 30 | deinit(this) 31 | } 32 | } 33 | 34 | let component: TestComponent 35 | let fixture: ComponentFixture 36 | let source: Subject 37 | 38 | beforeEach(async(() => { 39 | TestBed.configureTestingModule({ 40 | declarations: [ TestComponent ] 41 | }) 42 | .compileComponents() 43 | })) 44 | 45 | beforeEach(() => { 46 | source = new Subject() 47 | fixture = TestBed.createComponent(TestComponent) 48 | component = fixture.componentInstance 49 | fixture.detectChanges() 50 | }) 51 | 52 | it('should set initial value', () => { 53 | expect(component.foo).toBe(0) 54 | expect(component.bar).toBe(1) 55 | }) 56 | 57 | it('should support assigning value', () => { 58 | component.foo = 10 59 | 60 | fixture.detectChanges() 61 | expect(fixture.nativeElement.textContent).toBe('10-1-2') 62 | }) 63 | 64 | it('should update binding value', () => { 65 | source.next(11) 66 | expect(component.bar).toBe(11) 67 | 68 | fixture.detectChanges() 69 | expect(fixture.nativeElement.textContent).toBe('0-11-12') 70 | }) 71 | 72 | it('should reset state', () => { 73 | component.foo = 10 74 | fixture.detectChanges() 75 | 76 | reset(component.foo) 77 | fixture.detectChanges() 78 | 79 | expect(component.foo).toBe(0) 80 | }) 81 | }) 82 | 83 | describe('with base class', () => { 84 | @Component({ 85 | template: `{{foo}}-{{bar}}`, 86 | }) 87 | class TestComponent extends Reactive { 88 | foo = state(0) 89 | bar = state(1) 90 | baz = state(2) 91 | flag = false 92 | 93 | constructor(injector: Injector) { 94 | super(injector) 95 | } 96 | 97 | update(cgs: StateChanges, first: boolean): void { 98 | if (first) { 99 | bind(this.foo, source) 100 | bind(this.bar, source.pipe(map(x => x + 1))) 101 | } 102 | 103 | if (updateOn(cgs.baz)) { 104 | this.flag = true 105 | } 106 | } 107 | } 108 | 109 | let component: TestComponent 110 | let fixture: ComponentFixture 111 | let source: Subject 112 | 113 | beforeEach(async(() => { 114 | TestBed.configureTestingModule({ 115 | declarations: [ TestComponent ] 116 | }) 117 | .compileComponents() 118 | })) 119 | 120 | beforeEach(() => { 121 | source = new Subject() 122 | fixture = TestBed.createComponent(TestComponent) 123 | component = fixture.componentInstance 124 | fixture.detectChanges() 125 | }) 126 | 127 | it('should set initial value', () => { 128 | expect(component.foo).toBe(0) 129 | expect(component.bar).toBe(1) 130 | }) 131 | 132 | it('should update binding value', () => { 133 | source.next(10) 134 | 135 | expect(component.foo).toBe(10) 136 | expect(component.bar).toBe(11) 137 | 138 | fixture.detectChanges() 139 | expect(fixture.nativeElement.textContent).toBe('10-11') 140 | }) 141 | 142 | it('should respond on changes', () => { 143 | expect(component.flag).toBe(false) 144 | 145 | component.baz = 10 146 | fixture.detectChanges() 147 | expect(component.flag).toBe(true) 148 | }) 149 | 150 | it('should reset state', () => { 151 | component.foo = 10 152 | fixture.detectChanges() 153 | 154 | reset(component.foo) 155 | fixture.detectChanges() 156 | 157 | expect(component.foo).toBe(0) 158 | }) 159 | }) 160 | 161 | describe('change detection', () => { 162 | @Component({ 163 | selector: 'test-reactive', 164 | template: `{{foo}}`, 165 | changeDetection: ChangeDetectionStrategy.OnPush, 166 | }) 167 | class ReactiveComponent { 168 | foo = state(0) 169 | 170 | constructor(private injector: Injector) {} 171 | 172 | ngOnInit() { 173 | init(this, this.injector) 174 | } 175 | 176 | ngOnDestroy() { 177 | deinit(this) 178 | } 179 | } 180 | 181 | @Component({ 182 | template: ``, 183 | }) 184 | class TestComponent {} 185 | 186 | let component: TestComponent 187 | let fixture: ComponentFixture 188 | 189 | beforeEach(async(() => { 190 | TestBed.configureTestingModule({ 191 | declarations: [ TestComponent, ReactiveComponent ] 192 | }) 193 | .compileComponents() 194 | })) 195 | 196 | beforeEach(() => { 197 | fixture = TestBed.createComponent(TestComponent) 198 | component = fixture.componentInstance 199 | fixture.detectChanges() 200 | }) 201 | 202 | it('should mark view dirty', () => { 203 | // tslint:disable-next-line:deprecation 204 | const reactive = fixture.debugElement.query(By.directive(ReactiveComponent)).injector.get(ReactiveComponent) 205 | reactive.foo = 2 206 | 207 | fixture.detectChanges() 208 | expect(fixture.nativeElement.textContent).toBe('2') 209 | }) 210 | }) 211 | -------------------------------------------------------------------------------- /projects/ng-reactive/src/lib/ng-reactive.ts: -------------------------------------------------------------------------------- 1 | import { AfterViewChecked, ChangeDetectorRef, Injectable, Injector, OnChanges, OnDestroy, OnInit, SimpleChange, SimpleChanges, Type } from '@angular/core' 2 | import { Observable } from 'rxjs' 3 | import { instanceRecords, protoRecords, InstanceMeta, InstanceMetaKey, InstancePropertyMeta, InstancePropertyMetaKey, ProtoMetaKey, ProtoPropertyMetaKey } from './metadata' 4 | import { deleteProperty, getProperty, setProperty } from './util' 5 | 6 | export interface State { 7 | __ng_reactive_state: boolean 8 | data: T 9 | source: Observable | null 10 | } 11 | 12 | export function state(value: T): T { 13 | return { 14 | __ng_reactive_state: true, 15 | data: value, 16 | source: null, 17 | } as State as any 18 | } 19 | 20 | function isReactiveState(value: unknown): value is State { 21 | return value != null && typeof value === 'object' && (value as any).__ng_reactive_state != null 22 | } 23 | 24 | let activeInstance: object | null = null 25 | let activeProperty: string | null = null 26 | 27 | let pendingBindingProperties: string[] = [] 28 | let pendingBindingSources: Observable[] = [] 29 | 30 | export function init(instance: object, injector: Injector): void { 31 | let instanceMeta = instanceRecords.get(instance) 32 | if (instanceMeta != null) { 33 | if (instanceMeta[InstanceMetaKey.patched]) { return } 34 | } else { 35 | instanceMeta = [false, false, Object.create(null)] as InstanceMeta 36 | instanceRecords.set(instance, instanceMeta) 37 | } 38 | 39 | const properties = Object.keys(instance) 40 | const cdRef = injector.get(ChangeDetectorRef as Type) 41 | 42 | for (let i = 0; i < properties.length; i++) { 43 | const property = properties[i] 44 | const content = getProperty(instance, property) 45 | if (!isReactiveState(content)) { continue } 46 | 47 | const defaultValue = content.data 48 | instanceMeta[InstanceMetaKey.properties][property] = [defaultValue, defaultValue, null, false, 0, null] as InstancePropertyMeta 49 | 50 | deleteProperty(instance, property) 51 | const field = patchProtoProperty(instance, property, cdRef) 52 | field.set(instance, defaultValue) 53 | 54 | if (content.source != null) { 55 | pendingBindingProperties.push(property) 56 | pendingBindingSources.push(content.source) 57 | } 58 | } 59 | 60 | instanceMeta[InstanceMetaKey.patched] = true 61 | 62 | for (let i = 0; i < pendingBindingProperties.length; i++) { 63 | const property = pendingBindingProperties[i] 64 | const source = pendingBindingSources[i] 65 | const propertyMeta = instanceMeta[InstanceMetaKey.properties][property] 66 | 67 | if (propertyMeta == null) { 68 | throw new Error(`Property patched failed for unknown reason!`) 69 | } 70 | 71 | propertyMeta[InstancePropertyMetaKey.subscription] = source.subscribe(value => { 72 | setProperty(instance, property, value) 73 | }) 74 | } 75 | 76 | pendingBindingProperties = [] 77 | pendingBindingSources = [] 78 | } 79 | 80 | function patchProtoProperty(instance: object, property: string, cdRef: ChangeDetectorRef): WeakMap { 81 | const proto = Object.getPrototypeOf(instance) 82 | let protoMeta = protoRecords.get(proto) 83 | let field: WeakMap 84 | 85 | if (protoMeta != null) { 86 | let propertyMeta = protoMeta[ProtoMetaKey.properties][property] 87 | if (propertyMeta != null) { 88 | return propertyMeta[ProtoPropertyMetaKey.field] 89 | } else { 90 | field = new WeakMap() 91 | propertyMeta = [field] 92 | } 93 | } else { 94 | protoMeta = [false, Object.create(null)] 95 | protoRecords.set(proto, protoMeta) 96 | field = new WeakMap() 97 | } 98 | 99 | protoMeta[ProtoMetaKey.properties][property] = [field] 100 | 101 | Object.defineProperty(proto, property, { 102 | set(value: unknown) { 103 | if (isReactiveState(value)) { 104 | Object.defineProperty(this, property, { 105 | writable: true, 106 | configurable: true, 107 | enumerable: true, 108 | value, 109 | }) 110 | return 111 | } 112 | 113 | const instanceMeta = instanceRecords.get(this) 114 | if (instanceMeta == null) { 115 | throw new Error(`Instance not patched but used for reactive state!`) 116 | } 117 | 118 | instanceMeta[InstanceMetaKey.hasPendingChange] = true 119 | 120 | const propertyMeta = instanceMeta[InstanceMetaKey.properties][property] 121 | propertyMeta[InstancePropertyMetaKey.previousValue] = propertyMeta[InstancePropertyMetaKey.currentValue] 122 | propertyMeta[InstancePropertyMetaKey.currentValue] = value 123 | propertyMeta[InstancePropertyMetaKey.hasPendingChange] = true 124 | propertyMeta[InstancePropertyMetaKey.changesCount]++ 125 | 126 | field.set(this, value) 127 | cdRef.markForCheck() 128 | }, 129 | get(): unknown { 130 | activeInstance = this 131 | activeProperty = property 132 | return field.get(this) 133 | }, 134 | }) 135 | 136 | return field 137 | } 138 | 139 | export function inited(instance: object): boolean { 140 | if (instanceRecords.has(instance)) { 141 | return instanceRecords.get(instance)![InstanceMetaKey.patched] 142 | } 143 | return false 144 | } 145 | 146 | export function deinit(instance: object): void { 147 | const instanceMeta = instanceRecords.get(instance) 148 | 149 | if (instanceMeta == null) { 150 | return 151 | } 152 | 153 | const propertyMetaMap = instanceMeta[InstanceMetaKey.properties] 154 | const properties = Object.keys(propertyMetaMap) 155 | 156 | for (let i = 0; i < properties.length; i++) { 157 | const property = properties[i] 158 | const subscription = propertyMetaMap[property][InstancePropertyMetaKey.subscription] 159 | if (subscription != null) { 160 | subscription.unsubscribe() 161 | } 162 | } 163 | 164 | instanceMeta[InstanceMetaKey.properties] = Object.create(null) 165 | instanceRecords.delete(instance) 166 | } 167 | 168 | export function bind(target: T, source: Observable): void { 169 | if (isReactiveState(target)) { 170 | target.source = source 171 | return 172 | } 173 | 174 | if (activeInstance == null || activeProperty == null) { 175 | throw new Error(`The property to bind is not properly initialized!`) 176 | } 177 | 178 | const instance = activeInstance 179 | const property = activeProperty 180 | 181 | const instanceMeta = instanceRecords.get(instance) 182 | if (instanceMeta == null) { 183 | throw new Error(`The property to bind is not properly initialized!`) 184 | } 185 | 186 | const propertyMeta = instanceMeta[InstanceMetaKey.properties][property] 187 | if (propertyMeta == null) { 188 | throw new Error(`The property to bind is not properly initialized!`) 189 | } 190 | 191 | const subscription = propertyMeta[InstancePropertyMetaKey.subscription] 192 | if (subscription != null) { 193 | subscription.unsubscribe() 194 | } 195 | propertyMeta[InstancePropertyMetaKey.subscription] = source.subscribe(value => { 196 | setProperty(instance, property, value) 197 | }) 198 | 199 | activeInstance = null 200 | activeProperty = null 201 | } 202 | 203 | export function unbind(target: T): void { 204 | if (isReactiveState(target)) { 205 | target.source = null 206 | return 207 | } 208 | 209 | if (activeInstance == null || activeProperty == null) { 210 | throw new Error(`The property to unbind is not properly initialized!`) 211 | } 212 | 213 | const instance = activeInstance 214 | const property = activeProperty 215 | 216 | const instanceMeta = instanceRecords.get(instance) 217 | if (instanceMeta == null) { 218 | throw new Error(`The property to unbind is not properly initialized!`) 219 | } 220 | 221 | const propertyMeta = instanceMeta[InstanceMetaKey.properties][property] 222 | if (propertyMeta == null) { 223 | throw new Error(`The property to unbind is not properly initialized!`) 224 | } 225 | 226 | const subscription = propertyMeta[InstancePropertyMetaKey.subscription] 227 | if (subscription != null) { 228 | subscription.unsubscribe() 229 | propertyMeta[InstancePropertyMetaKey.subscription] = null 230 | } 231 | } 232 | 233 | export function reset(target: T): void { 234 | if (isReactiveState(target)) { 235 | return 236 | } 237 | 238 | if (activeInstance == null || activeProperty == null) { 239 | throw new Error(`The property to reset is not properly initialized!`) 240 | } 241 | 242 | const instance = activeInstance 243 | const property = activeProperty 244 | 245 | const instanceMeta = instanceRecords.get(instance) 246 | if (instanceMeta == null) { 247 | throw new Error(`The property to unbind is not properly initialized!`) 248 | } 249 | 250 | const propertyMeta = instanceMeta[InstanceMetaKey.properties][property] 251 | if (propertyMeta == null) { 252 | throw new Error(`The property to unbind is not properly initialized!`) 253 | } 254 | 255 | setProperty(instance, property, propertyMeta[InstancePropertyMetaKey.defaultValue]) 256 | } 257 | 258 | let viewActions: (() => void)[] | null = null 259 | 260 | export function updateOn(...changes: (StateChange | null | undefined)[]): boolean { 261 | return changes.some(change => change != null) 262 | } 263 | 264 | export function viewUpdate(callback: () => void) { 265 | if (viewActions == null) { 266 | throw new Error(`Cannot schedule view change outside "update" method!`) 267 | } 268 | 269 | viewActions.push(callback) 270 | } 271 | 272 | function getReactiveChanges(instance: object): SimpleChanges { 273 | const instanceMeta = instanceRecords.get(instance) 274 | if (instanceMeta == null) { 275 | throw new Error(`The property to unbind is not properly initialized!`) 276 | } 277 | 278 | if (!instanceMeta[InstanceMetaKey.hasPendingChange]) { 279 | return {} 280 | } 281 | 282 | const result: SimpleChanges = {} 283 | const propertyMetaMap = instanceMeta[InstanceMetaKey.properties] 284 | const properties = Object.keys(propertyMetaMap) 285 | 286 | for (let i = 0; i < properties.length; i++) { 287 | const property = properties[i] 288 | const propertyMeta = propertyMetaMap[property] 289 | 290 | if (propertyMeta[InstancePropertyMetaKey.hasPendingChange]) { 291 | result[property] = new SimpleChange( 292 | propertyMeta[InstancePropertyMetaKey.previousValue], 293 | propertyMeta[InstancePropertyMetaKey.currentValue], 294 | propertyMeta[InstancePropertyMetaKey.changesCount] === 1, 295 | ) 296 | } 297 | } 298 | 299 | instanceMeta[InstanceMetaKey.hasPendingChange] = false 300 | 301 | return result 302 | } 303 | 304 | export interface StateChange { 305 | previousValue: T 306 | currentValue: T 307 | firstChange: boolean 308 | } 309 | 310 | export type StateChanges = { 311 | [prop in keyof T]: StateChange; 312 | } 313 | 314 | const pendingChangesRecord = new WeakMap() 315 | 316 | @Injectable() 317 | export abstract class Reactive implements AfterViewChecked, OnChanges, OnDestroy, OnInit { 318 | private __injector: Injector 319 | private __viewActions: (() => void)[] = [] 320 | 321 | constructor(injector: Injector) { 322 | this.__injector = injector 323 | } 324 | 325 | abstract update(changes: StateChanges, first: boolean): void 326 | 327 | ngOnChanges(changes: SimpleChanges): void { 328 | pendingChangesRecord.set(this, changes) 329 | } 330 | 331 | ngOnInit(): void { 332 | init(this, this.__injector) 333 | 334 | const changes = pendingChangesRecord.get(this) || {} 335 | viewActions = [] 336 | this.__invokeUpdateFn(changes, true) 337 | pendingChangesRecord.delete(this) 338 | } 339 | 340 | ngDoCheck(): void { 341 | let changes = getReactiveChanges(this) 342 | 343 | if (pendingChangesRecord.has(this)) { 344 | const ngChanges = pendingChangesRecord.get(this)! 345 | pendingChangesRecord.delete(this) 346 | changes = { ...ngChanges, ...changes } 347 | } 348 | 349 | if (Object.keys(changes).length > 0) { 350 | this.__invokeUpdateFn(changes, false) 351 | } 352 | } 353 | 354 | ngAfterViewChecked(): void { 355 | for (let i = 0; i < this.__viewActions.length; i++) { 356 | this.__viewActions[i]() 357 | } 358 | this.__viewActions = [] 359 | } 360 | 361 | ngOnDestroy(): void { 362 | deinit(this) 363 | } 364 | 365 | private __invokeUpdateFn(changes: SimpleChanges, first: boolean) { 366 | viewActions = [] 367 | this.update(changes as any, first) 368 | this.__viewActions.push(...viewActions) 369 | viewActions = null 370 | } 371 | } 372 | -------------------------------------------------------------------------------- /projects/ng-reactive/src/lib/util.ts: -------------------------------------------------------------------------------- 1 | export function setProperty(instance: object, property: string, value: T) { 2 | (instance as any)[property] = value 3 | } 4 | 5 | export function getProperty(instance: object, property: string): T { 6 | return (instance as any)[property] 7 | } 8 | 9 | export function deleteProperty(instance: object, property: string): void { 10 | delete (instance as any)[property] 11 | } 12 | -------------------------------------------------------------------------------- /projects/ng-reactive/src/public-api.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Public API Surface of ng-reactive 3 | */ 4 | 5 | export * from './lib/ng-reactive' 6 | -------------------------------------------------------------------------------- /projects/ng-reactive/src/test.ts: -------------------------------------------------------------------------------- 1 | // This file is required by karma.conf.js and loads recursively all the .spec and framework files 2 | // tslint:disable:ordered-imports 3 | 4 | import 'zone.js/dist/zone' 5 | import 'zone.js/dist/zone-testing' 6 | import { getTestBed } from '@angular/core/testing' 7 | import { 8 | BrowserDynamicTestingModule, 9 | platformBrowserDynamicTesting 10 | } from '@angular/platform-browser-dynamic/testing' 11 | 12 | declare const require: any 13 | 14 | // First, initialize the Angular testing environment. 15 | getTestBed().initTestEnvironment( 16 | BrowserDynamicTestingModule, 17 | platformBrowserDynamicTesting() 18 | ) 19 | // Then we find all the tests. 20 | const context = require.context('./', true, /\.spec\.ts$/) 21 | // And load the modules. 22 | context.keys().map(context) 23 | -------------------------------------------------------------------------------- /projects/ng-reactive/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../out-tsc/lib", 5 | "target": "es2015", 6 | "declaration": true, 7 | "inlineSources": true, 8 | "types": [], 9 | "lib": [ 10 | "dom", 11 | "es2018" 12 | ] 13 | }, 14 | "angularCompilerOptions": { 15 | "annotateForClosureCompiler": true, 16 | "skipTemplateCodegen": true, 17 | "strictMetadataEmit": true, 18 | "fullTemplateTypeCheck": true, 19 | "strictInjectionParameters": true, 20 | "enableResourceInlining": true 21 | }, 22 | "exclude": [ 23 | "src/test.ts", 24 | "**/*.spec.ts" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /projects/ng-reactive/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../out-tsc/spec", 5 | "types": [ 6 | "jasmine", 7 | "node" 8 | ] 9 | }, 10 | "files": [ 11 | "src/test.ts" 12 | ], 13 | "include": [ 14 | "**/*.spec.ts", 15 | "**/*.d.ts" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /projects/ng-reactive/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tslint.json", 3 | "rules": { 4 | "directive-selector": [ 5 | false, 6 | "attribute", 7 | "lib", 8 | "camelCase" 9 | ], 10 | "component-selector": [ 11 | false, 12 | "element", 13 | "lib", 14 | "kebab-case" 15 | ] 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/app/app.component.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trotyl/ng-reactive/9d26fbea0fc8d73ce02e3a29c01ad6994076db7f/src/app/app.component.css -------------------------------------------------------------------------------- /src/app/app.component.html: -------------------------------------------------------------------------------- 1 | 2 |
3 |

4 | Welcome to {{ title }}! ~ {{ count }} 5 |

6 | Angular Logo 7 |
8 |

Here are some links to help you start:

9 | 20 | -------------------------------------------------------------------------------- /src/app/app.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, TestBed } from '@angular/core/testing' 2 | import { AppComponent } from './app.component' 3 | 4 | describe('AppComponent', () => { 5 | beforeEach(async(() => { 6 | TestBed.configureTestingModule({ 7 | declarations: [ 8 | AppComponent 9 | ], 10 | }).compileComponents() 11 | })) 12 | 13 | it('should create the app', () => { 14 | const fixture = TestBed.createComponent(AppComponent) 15 | const app = fixture.debugElement.componentInstance 16 | expect(app).toBeTruthy() 17 | }) 18 | 19 | it(`should have as title 'ng-reactive'`, () => { 20 | const fixture = TestBed.createComponent(AppComponent) 21 | const app = fixture.debugElement.componentInstance 22 | expect(app.title).toEqual('ng-reactive') 23 | }) 24 | 25 | it('should render title in a h1 tag', () => { 26 | const fixture = TestBed.createComponent(AppComponent) 27 | fixture.detectChanges() 28 | const compiled = fixture.debugElement.nativeElement 29 | expect(compiled.querySelector('h1').textContent).toContain('Welcome to ng-reactive!') 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component } from '@angular/core' 2 | import { bind, state, updateOn, Reactive, StateChanges } from 'ng-reactive' 3 | import { interval, Observable } from 'rxjs' 4 | import { map } from 'rxjs/operators' 5 | 6 | @Component({ 7 | selector: 'app-root', 8 | templateUrl: './app.component.html', 9 | styleUrls: ['./app.component.css'], 10 | changeDetection: ChangeDetectionStrategy.OnPush, 11 | }) 12 | export class AppComponent extends Reactive { 13 | title = 'ng-reactive' 14 | 15 | count = state(0) 16 | 17 | update(changes: StateChanges, first: boolean): void { 18 | if (first) { 19 | bind(this.count, this.makeHeartbeat()) 20 | } 21 | 22 | if (updateOn(changes.count)) { 23 | console.log(`count changed to ${this.count}`) 24 | } 25 | } 26 | 27 | private makeHeartbeat(): Observable { 28 | return interval(1000).pipe(map(x => x + 1)) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core' 2 | import { BrowserModule } from '@angular/platform-browser' 3 | 4 | import { AppComponent } from './app.component' 5 | 6 | @NgModule({ 7 | declarations: [ 8 | AppComponent 9 | ], 10 | imports: [ 11 | BrowserModule 12 | ], 13 | providers: [], 14 | bootstrap: [AppComponent] 15 | }) 16 | export class AppModule { } 17 | -------------------------------------------------------------------------------- /src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trotyl/ng-reactive/9d26fbea0fc8d73ce02e3a29c01ad6994076db7f/src/assets/.gitkeep -------------------------------------------------------------------------------- /src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true 3 | } 4 | -------------------------------------------------------------------------------- /src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | // This file can be replaced during build by using the `fileReplacements` array. 2 | // `ng build --prod` replaces `environment.ts` with `environment.prod.ts`. 3 | // The list of file replacements can be found in `angular.json`. 4 | 5 | export const environment = { 6 | production: false 7 | } 8 | 9 | /* 10 | * For easier debugging in development mode, you can import the following file 11 | * to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`. 12 | * 13 | * This import should be commented out in production mode because it will have a negative impact 14 | * on performance if an error is thrown. 15 | */ 16 | // import 'zone.js/dist/zone-error'; // Included with Angular CLI. 17 | -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trotyl/ng-reactive/9d26fbea0fc8d73ce02e3a29c01ad6994076db7f/src/favicon.ico -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NgReactive 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { enableProdMode } from '@angular/core' 2 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic' 3 | 4 | import { AppModule } from './app/app.module' 5 | import { environment } from './environments/environment' 6 | 7 | if (environment.production) { 8 | enableProdMode() 9 | } 10 | 11 | platformBrowserDynamic().bootstrapModule(AppModule) 12 | .catch(err => console.error(err)) 13 | -------------------------------------------------------------------------------- /src/polyfills.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file includes polyfills needed by Angular and is loaded before the app. 3 | * You can add your own extra polyfills to this file. 4 | * 5 | * This file is divided into 2 sections: 6 | * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. 7 | * 2. Application imports. Files imported after ZoneJS that should be loaded before your main 8 | * file. 9 | * 10 | * The current setup is for so-called "evergreen" browsers; the last versions of browsers that 11 | * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera), 12 | * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile. 13 | * 14 | * Learn more in https://angular.io/guide/browser-support 15 | */ 16 | 17 | /*************************************************************************************************** 18 | * BROWSER POLYFILLS 19 | */ 20 | 21 | /** IE10 and IE11 requires the following for NgClass support on SVG elements */ 22 | // import 'classlist.js' // Run `npm install --save classlist.js`. 23 | 24 | /** 25 | * Web Animations `@angular/platform-browser/animations` 26 | * Only required if AnimationBuilder is used within the application and using IE/Edge or Safari. 27 | * Standard animation support in Angular DOES NOT require any polyfills (as of Angular 6.0). 28 | */ 29 | // import 'web-animations-js' // Run `npm install --save web-animations-js`. 30 | 31 | /** 32 | * By default, zone.js will patch all possible macroTask and DomEvents 33 | * user can disable parts of macroTask/DomEvents patch by setting following flags 34 | * because those flags need to be set before `zone.js` being loaded, and webpack 35 | * will put import in the top of bundle, so user need to create a separate file 36 | * in this directory (for example: zone-flags.ts), and put the following flags 37 | * into that file, and then add the following code before importing zone.js. 38 | * import './zone-flags.ts'; 39 | * 40 | * The flags allowed in zone-flags.ts are listed here. 41 | * 42 | * The following flags will work for all browsers. 43 | * 44 | * (window as any).__Zone_disable_requestAnimationFrame = true // disable patch requestAnimationFrame 45 | * (window as any).__Zone_disable_on_property = true // disable patch onProperty such as onclick 46 | * (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove'] // disable patch specified eventNames 47 | * 48 | * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js 49 | * with the following flag, it will bypass `zone.js` patch for IE/Edge 50 | * 51 | * (window as any).__Zone_enable_cross_context_check = true 52 | * 53 | */ 54 | 55 | /*************************************************************************************************** 56 | * Zone JS is required by default for Angular itself. 57 | */ 58 | import 'zone.js/dist/zone' // Included with Angular CLI. 59 | 60 | 61 | /*************************************************************************************************** 62 | * APPLICATION IMPORTS 63 | */ 64 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | /* You can add global styles to this file, and also import other style files */ 2 | -------------------------------------------------------------------------------- /src/test.ts: -------------------------------------------------------------------------------- 1 | // This file is required by karma.conf.js and loads recursively all the .spec and framework files 2 | // tslint:disable:ordered-imports 3 | 4 | import 'zone.js/dist/zone-testing' 5 | import { getTestBed } from '@angular/core/testing' 6 | import { 7 | BrowserDynamicTestingModule, 8 | platformBrowserDynamicTesting 9 | } from '@angular/platform-browser-dynamic/testing' 10 | 11 | declare const require: any 12 | 13 | // First, initialize the Angular testing environment. 14 | getTestBed().initTestEnvironment( 15 | BrowserDynamicTestingModule, 16 | platformBrowserDynamicTesting() 17 | ) 18 | // Then we find all the tests. 19 | const context = require.context('./', true, /\.spec\.ts$/) 20 | // And load the modules. 21 | context.keys().map(context) 22 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./out-tsc/app", 5 | "types": [] 6 | }, 7 | "include": [ 8 | "src/**/*.ts" 9 | ], 10 | "exclude": [ 11 | "src/test.ts", 12 | "src/**/*.spec.ts" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "baseUrl": "./", 5 | "outDir": "./dist/out-tsc", 6 | "sourceMap": true, 7 | "declaration": false, 8 | "downlevelIteration": true, 9 | "experimentalDecorators": true, 10 | "module": "esnext", 11 | "moduleResolution": "node", 12 | "importHelpers": true, 13 | "target": "es2015", 14 | "strict": true, 15 | "typeRoots": [ 16 | "node_modules/@types" 17 | ], 18 | "lib": [ 19 | "es2018", 20 | "dom" 21 | ], 22 | "paths": { 23 | "ng-reactive": [ 24 | "dist/ng-reactive" 25 | ], 26 | "ng-reactive/*": [ 27 | "dist/ng-reactive/*" 28 | ] 29 | } 30 | }, 31 | "angularCompilerOptions": { 32 | "fullTemplateTypeCheck": true, 33 | "strictInjectionParameters": true 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./out-tsc/spec", 5 | "types": [ 6 | "jasmine", 7 | "node" 8 | ] 9 | }, 10 | "files": [ 11 | "src/test.ts", 12 | "src/polyfills.ts" 13 | ], 14 | "include": [ 15 | "src/**/*.spec.ts", 16 | "src/**/*.d.ts" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tslint:recommended", 3 | "rules": { 4 | "array-type": false, 5 | "arrow-parens": false, 6 | "deprecation": { 7 | "severity": "warning" 8 | }, 9 | "component-class-suffix": false, 10 | "contextual-lifecycle": false, 11 | "directive-class-suffix": false, 12 | "directive-selector": [ 13 | false, 14 | "attribute", 15 | "app", 16 | "camelCase" 17 | ], 18 | "component-selector": [ 19 | false, 20 | "element", 21 | "app", 22 | "kebab-case" 23 | ], 24 | "import-blacklist": [ 25 | true, 26 | "rxjs/Rx" 27 | ], 28 | "interface-name": false, 29 | "max-classes-per-file": false, 30 | "max-line-length": [ 31 | true, 32 | { 33 | "limit": 140, 34 | "ignore-pattern": "^import |^export {(.*?)}" 35 | } 36 | ], 37 | "member-access": false, 38 | "member-ordering": [ 39 | true, 40 | { 41 | "order": [ 42 | "static-field", 43 | "instance-field", 44 | "static-method", 45 | "instance-method" 46 | ] 47 | } 48 | ], 49 | "no-consecutive-blank-lines": false, 50 | "no-console": [ 51 | true, 52 | "debug", 53 | "info", 54 | "time", 55 | "timeEnd", 56 | "trace" 57 | ], 58 | "no-empty": false, 59 | "no-inferrable-types": [ 60 | true, 61 | "ignore-params", 62 | "ignore-properties" 63 | ], 64 | "no-non-null-assertion": false, 65 | "no-redundant-jsdoc": true, 66 | "no-switch-case-fall-through": true, 67 | "no-use-before-declare": true, 68 | "no-var-requires": false, 69 | "object-literal-key-quotes": [ 70 | true, 71 | "as-needed" 72 | ], 73 | "object-literal-sort-keys": false, 74 | "ordered-imports": [ 75 | true, 76 | { 77 | "import-sources-order": "lowercase-last", 78 | "named-imports-order": "lowercase-first" 79 | } 80 | ], 81 | "prefer-for-of": false, 82 | "quotemark": [ 83 | true, 84 | "single" 85 | ], 86 | "trailing-comma": false, 87 | "no-conflicting-lifecycle": false, 88 | "no-host-metadata-property": true, 89 | "no-input-rename": true, 90 | "no-inputs-metadata-property": true, 91 | "no-output-native": true, 92 | "no-output-on-prefix": true, 93 | "no-output-rename": true, 94 | "no-outputs-metadata-property": true, 95 | "semicolon": [ 96 | true, 97 | "never" 98 | ], 99 | "template-banana-in-box": true, 100 | "template-no-negated-async": true, 101 | "use-lifecycle-interface": false, 102 | "use-pipe-transform-interface": true, 103 | "variable-name": false 104 | }, 105 | "rulesDirectory": [ 106 | "codelyzer" 107 | ] 108 | } --------------------------------------------------------------------------------