├── .nvmrc ├── .github ├── FUNDING.yml └── workflows │ ├── release-release.yml │ ├── release-deploy.yml │ ├── release-publish.yml │ └── quality.yml ├── .gitignore ├── docs ├── browser-icons │ ├── chrome.png │ ├── edge.png │ ├── nodejs.png │ ├── firefox.png │ ├── safari-ios.png │ └── edge-chromium.png ├── security.md ├── snippets │ ├── observable-from-example.js │ └── object-observer-ctor-example.js ├── dom-like-api.md ├── filter-paths.md ├── sync-async.md ├── cdn.md ├── observable.md ├── changelog.md ├── performance-report.md └── filter-graphs │ ├── filter-paths.svg │ ├── filter-paths-from.svg │ └── filter-paths-of.svg ├── ci ├── tools │ ├── stdout.js │ ├── integrity-utils.js │ └── build-utils.js └── eslint.config.mjs ├── sri.json ├── tests ├── configs │ ├── tests-config-ci-node.json │ ├── tests-config-ci-chromium.json │ ├── tests-config-ci-firefox.json │ └── tests-config-ci-webkit.json ├── browser-host-objects.js ├── workers │ ├── perf-worker.js │ ├── perf-sync-test-b.js │ ├── perf-async-test-b.js │ ├── perf-sync-test-a.js │ └── perf-async-test-a.js ├── object-observer-objects-same-refs.js ├── revokation.js ├── reassignment-of-equals.js ├── object-generics.js ├── cross-instance.js ├── object-observer-performance-sync.js ├── object-observer-performance-async.js ├── object-observer-objects-circular.js ├── object-observer-native-objects-to-skip.js ├── object-observer-objects-async.js ├── api-base.js ├── object-observer-subgraphs.js ├── listeners.js ├── object-observer-api.js ├── unobserve.js ├── api-changes.js ├── object-observer-arrays-copy-within.js ├── observe-specific-paths.js ├── object-observer-objects.js ├── object-observer-arrays-typed.js ├── observable-nested.js └── object-observer-arrays.js ├── license ├── package.json ├── src └── object-observer.d.ts └── readme.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 22.14.0 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | custom: "https://paypal.me/gullerya" 2 | tidelift: "npm/object-observer" 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | df*.iml 2 | .idea/ 3 | .vscode/ 4 | /dist 5 | **/node_modules/ 6 | **/reports 7 | .DS_Store -------------------------------------------------------------------------------- /docs/browser-icons/chrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gullerya/object-observer/HEAD/docs/browser-icons/chrome.png -------------------------------------------------------------------------------- /docs/browser-icons/edge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gullerya/object-observer/HEAD/docs/browser-icons/edge.png -------------------------------------------------------------------------------- /docs/browser-icons/nodejs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gullerya/object-observer/HEAD/docs/browser-icons/nodejs.png -------------------------------------------------------------------------------- /docs/browser-icons/firefox.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gullerya/object-observer/HEAD/docs/browser-icons/firefox.png -------------------------------------------------------------------------------- /docs/browser-icons/safari-ios.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gullerya/object-observer/HEAD/docs/browser-icons/safari-ios.png -------------------------------------------------------------------------------- /docs/browser-icons/edge-chromium.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gullerya/object-observer/HEAD/docs/browser-icons/edge-chromium.png -------------------------------------------------------------------------------- /ci/tools/stdout.js: -------------------------------------------------------------------------------- 1 | import os from 'node:os'; 2 | 3 | export function writeNewline() { 4 | process.stdout.write(os.EOL); 5 | } 6 | 7 | export function writeGreen(string) { 8 | process.stdout.write(`\x1B[32m${string}\x1B[0m`); 9 | } 10 | 11 | export function write(string) { 12 | process.stdout.write(string); 13 | } 14 | -------------------------------------------------------------------------------- /docs/security.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | __At least 2 latest major__ versions will have a security patches provided. 6 | 7 | ## Reporting a Vulnerability 8 | 9 | Security vulnerability should be reported via the [issues](https://github.com/gullerya/object-observer/issues). 10 | When adding security issue, please tag it as a critical one. -------------------------------------------------------------------------------- /sri.json: -------------------------------------------------------------------------------- 1 | { 2 | "dist/cdn/object-observer.js": "sha512-Jc51tpJGoR6MHLupRabCfXEVroGC2M5QEH19brhsgJ42vJnsIBUgDg4CeaTES8jZfQTLwzPp5mjIXg64cdEzAg==", 3 | "dist/cdn/object-observer.min.js": "sha512-jieNuEyCm4guZuELCk+tZ8ijFhxyw6cOhx4q8cimhqdNccson04GDZpGm5kISKIkI//xERXAep1bt5HWKLDDNw==", 4 | "dist/cdn/object-observer.min.js.map": "sha512-oCCsAVsC1+BcyrA8KzysTfw0U1qH6HN3Z/HTzyBbJUc5aPRA4+W2Hp4HSwD2Z5CDbYyKFDKyle7YHLy0V8GA2Q==" 5 | } -------------------------------------------------------------------------------- /tests/configs/tests-config-ci-node.json: -------------------------------------------------------------------------------- 1 | { 2 | "environments": [ 3 | { 4 | "node": true, 5 | "tests": { 6 | "ttl": 32000, 7 | "maxFail": 0, 8 | "maxSkip": 5, 9 | "include": [ 10 | "./tests/*.js" 11 | ], 12 | "exclude": [ 13 | "**/tests/browser-host-objects.js", 14 | "**/tests/*-performance-*.js" 15 | ] 16 | }, 17 | "coverage": { 18 | "include": [ 19 | "./src/**/*.js" 20 | ] 21 | } 22 | } 23 | ] 24 | } -------------------------------------------------------------------------------- /tests/configs/tests-config-ci-chromium.json: -------------------------------------------------------------------------------- 1 | { 2 | "environments": [ 3 | { 4 | "browser": { 5 | "type": "chromium", 6 | "executors": { 7 | "type": "iframe" 8 | } 9 | }, 10 | "tests": { 11 | "ttl": 32000, 12 | "maxFail": 0, 13 | "maxSkip": 5, 14 | "include": [ 15 | "./tests/*.js" 16 | ], 17 | "exclude": [ 18 | "**/tests/*-performance-*.js" 19 | ] 20 | }, 21 | "coverage": { 22 | "include": [ 23 | "./src/**/*.js" 24 | ] 25 | } 26 | } 27 | ] 28 | } -------------------------------------------------------------------------------- /tests/browser-host-objects.js: -------------------------------------------------------------------------------- 1 | import { test } from '@gullerya/just-test'; 2 | import { assert } from '@gullerya/just-test/assert'; 3 | import { Observable } from '../src/object-observer.js'; 4 | 5 | test('test DOMStringMap', () => { 6 | const 7 | e = document.createElement('div'), 8 | oo = Observable.from(e.dataset), 9 | events = [], 10 | observer = changes => { 11 | events.push.apply(events, changes); 12 | }; 13 | 14 | Observable.observe(oo, observer); 15 | oo.some = 'thing'; 16 | assert.equal(events.length, 1); 17 | }); -------------------------------------------------------------------------------- /tests/workers/perf-worker.js: -------------------------------------------------------------------------------- 1 | import { parentPort } from 'node:worker_threads'; 2 | 3 | parentPort.unref(); 4 | parentPort.on('message', async message => { 5 | const { testUrl, testParams } = message; 6 | console.log(`executing test from '${testUrl}' with params ${JSON.stringify(testParams)}`); 7 | try { 8 | const test = (await import(`./${testUrl}`)).default; 9 | const result = await Promise.resolve(test(testParams)); 10 | parentPort.postMessage(result); 11 | } catch (error) { 12 | parentPort.postMessage({ error: { name: error.name, message: error.message, stack: error.stack } }); 13 | } 14 | }); -------------------------------------------------------------------------------- /tests/object-observer-objects-same-refs.js: -------------------------------------------------------------------------------- 1 | import { test } from '@gullerya/just-test'; 2 | import { assert } from '@gullerya/just-test/assert'; 3 | import { Observable } from '../src/object-observer.js'; 4 | 5 | test('subgraph objects pointing to the same object few times', { skip: true }, () => { 6 | const childObj = { prop: 'A' }; 7 | const obsMainObj = Observable.from({ childA: childObj, childB: childObj }); 8 | 9 | Observable.observe(obsMainObj, changes => console.dir(changes)); 10 | 11 | obsMainObj.childA.prop = 'B'; 12 | 13 | assert.strictEqual(obsMainObj.childA.prop, obsMainObj.childB.prop); 14 | }); -------------------------------------------------------------------------------- /tests/configs/tests-config-ci-firefox.json: -------------------------------------------------------------------------------- 1 | { 2 | "environments": [ 3 | { 4 | "browser": { 5 | "type": "firefox", 6 | "executors": { 7 | "type": "iframe" 8 | }, 9 | "importmap": { 10 | "imports": { 11 | "@gullerya/just-test": "/libs/@gullerya/just-test/bin/runner/just-test.js", 12 | "@gullerya/just-test/assert": "/libs/@gullerya/just-test/bin/common/assert-utils.js" 13 | } 14 | } 15 | }, 16 | "tests": { 17 | "ttl": 32000, 18 | "maxFail": 0, 19 | "maxSkip": 5, 20 | "include": [ 21 | "./tests/*.js" 22 | ], 23 | "exclude": [ 24 | "**/tests/*-performance-*.js" 25 | ] 26 | } 27 | } 28 | ] 29 | } -------------------------------------------------------------------------------- /tests/configs/tests-config-ci-webkit.json: -------------------------------------------------------------------------------- 1 | { 2 | "environments": [ 3 | { 4 | "browser": { 5 | "type": "webkit", 6 | "executors": { 7 | "type": "iframe" 8 | }, 9 | "importmap": { 10 | "imports": { 11 | "@gullerya/just-test": "/libs/@gullerya/just-test/bin/runner/just-test.js", 12 | "@gullerya/just-test/assert": "/libs/@gullerya/just-test/bin/common/assert-utils.js" 13 | } 14 | } 15 | }, 16 | "tests": { 17 | "ttl": 32000, 18 | "maxFail": 0, 19 | "maxSkip": 5, 20 | "include": [ 21 | "./tests/*.js" 22 | ], 23 | "exclude": [ 24 | "**/tests/*-performance-*.js" 25 | ] 26 | } 27 | } 28 | ] 29 | } -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | ISC License (ISC) 2 | 3 | Copyright 2015 Yuri Guller (gullerya@gmail.com) 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, 6 | provided that the above copyright notice and this permission notice appear in all copies. 7 | 8 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE 9 | INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. 10 | IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES 11 | OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, 12 | NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. -------------------------------------------------------------------------------- /ci/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import globals from 'globals'; 2 | import path from 'node:path'; 3 | import { fileURLToPath } from 'node:url'; 4 | import js from '@eslint/js'; 5 | import { FlatCompat } from '@eslint/eslintrc'; 6 | 7 | const __filename = fileURLToPath(import.meta.url); 8 | const __dirname = path.dirname(__filename); 9 | const compat = new FlatCompat({ 10 | baseDirectory: __dirname, 11 | recommendedConfig: js.configs.recommended, 12 | allConfig: js.configs.all 13 | }); 14 | 15 | export default [...compat.extends('eslint:recommended'), { 16 | languageOptions: { 17 | globals: { 18 | ...globals.browser, 19 | ...globals.node 20 | }, 21 | 22 | ecmaVersion: 'latest', 23 | sourceType: 'module' 24 | }, 25 | 26 | rules: { 27 | 'no-shadow': 2 28 | } 29 | }]; -------------------------------------------------------------------------------- /.github/workflows/release-release.yml: -------------------------------------------------------------------------------- 1 | name: Release (Git tag) 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | version: 7 | description: "Version bump kind" 8 | required: true 9 | type: choice 10 | options: ["patch", "minor", "major"] 11 | 12 | jobs: 13 | release: 14 | runs-on: ubuntu-20.04 15 | 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | with: 20 | token: ${{ secrets.OO_CI_AT }} 21 | 22 | - name: Setup NodeJS 23 | uses: actions/setup-node@v4 24 | with: 25 | node-version-file: '.nvmrc' 26 | 27 | - name: Install dependencies 28 | run: npm ci 29 | 30 | - name: Bump & Tag version 31 | run: | 32 | git config user.name "automation" 33 | git config user.email "ci.gullerya@gmail.com" 34 | npm version ${{ github.event.inputs.version }} 35 | -------------------------------------------------------------------------------- /ci/tools/integrity-utils.js: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs/promises'; 2 | import path from 'node:path'; 3 | import crypto from 'node:crypto'; 4 | 5 | export { 6 | calcIntegrity 7 | }; 8 | 9 | const HASHING_ALGO = 'sha512'; 10 | 11 | async function calcIntegrity(dir) { 12 | const result = {}; 13 | const files = await getFlatFilesList(dir); 14 | for (const file of files) { 15 | const text = await fs.readFile(path.join(file), { encoding: 'utf-8' }); 16 | const algo = crypto.createHash(HASHING_ALGO); 17 | const hash = algo.update(text, 'utf-8').digest().toString('base64'); 18 | result[file] = `${HASHING_ALGO}-${hash}`; 19 | } 20 | return Object.freeze(result); 21 | } 22 | 23 | async function getFlatFilesList(rootDir) { 24 | const result = []; 25 | const entries = await fs.readdir(rootDir, { withFileTypes: true }); 26 | for (const e of entries) { 27 | const ePath = path.join(rootDir, e.name); 28 | result.push(e.isDirectory() ? getFlatFilesList(ePath) : ePath); 29 | } 30 | return result.flat(Number.MAX_VALUE); 31 | } -------------------------------------------------------------------------------- /.github/workflows/release-deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy (CDN) 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | version: 7 | description: "Version to deploy (eg 1.2.3)" 8 | required: true 9 | type: string 10 | workflow_call: 11 | inputs: 12 | version: 13 | required: true 14 | type: string 15 | 16 | jobs: 17 | release-deploy: 18 | runs-on: ubuntu-20.04 19 | 20 | steps: 21 | - name: Checkout 22 | uses: actions/checkout@v4 23 | 24 | - name: Setup NodeJS 25 | uses: actions/setup-node@v4 26 | with: 27 | node-version-file: '.nvmrc' 28 | 29 | # Pull the object-observer version to be deployed 30 | - name: Install 31 | run: npm install -E @gullerya/object-observer@${{ inputs.version }} 32 | 33 | # Deploy 34 | - name: Deploy 35 | env: 36 | AWS_ACCESS_KEY_ID: ${{ secrets.AWS_S3_ACCESS }} 37 | AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_S3_SECRET }} 38 | AWS_REGION: eu-central-1 39 | run: aws s3 sync ./node_modules/@gullerya/object-observer/dist/cdn s3://${{ secrets.AWS_LIBS_BUCKET }}/object-observer/${{ inputs.version }} --delete --cache-control public,max-age=172800,immutable 40 | -------------------------------------------------------------------------------- /.github/workflows/release-publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish (NPM) 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | publish: 10 | runs-on: ubuntu-20.04 11 | outputs: 12 | packageVersion: ${{ steps.outputVersion.outputs.packageVersion }} 13 | 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | with: 18 | token: ${{ secrets.OO_CI_AT }} 19 | 20 | - name: Setup NodeJS 21 | uses: actions/setup-node@v4 22 | with: 23 | node-version-file: '.nvmrc' 24 | registry-url: "https://registry.npmjs.org" 25 | 26 | - name: Install dependencies 27 | run: npm ci 28 | 29 | - name: Build 30 | run: npm run build 31 | 32 | - name: Publish 33 | env: 34 | NODE_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} 35 | run: npm publish 36 | 37 | - name: Output version 38 | id: outputVersion 39 | run: echo "packageVersion=$(cat package.json | jq -r '.version')" >> $GITHUB_OUTPUT 40 | 41 | trigger-cdn-deploy: 42 | needs: publish 43 | uses: ./.github/workflows/release-deploy.yml 44 | with: 45 | version: ${{ needs.publish.outputs.packageVersion }} 46 | secrets: inherit 47 | -------------------------------------------------------------------------------- /docs/snippets/observable-from-example.js: -------------------------------------------------------------------------------- 1 | import { Observable } from 'https://libs.gullerya.com/object-observer/4.2.2/object-observer.js'; 2 | 3 | // raw data created in client or fetched from server 4 | let user = { 5 | name: 'Nava', 6 | age: 8, 7 | address: { 8 | street: 'Noway', 9 | block: 50, 10 | city: 'Sin City', 11 | country: 'Neverland' 12 | } 13 | }; 14 | 15 | // setup observation 16 | let observableUser = Observable.from(user); 17 | Observable.observe(observableUser, changes => console.log(changes)); 18 | 19 | // now lets play with some changes to see the results (see the console log in dev tools) 20 | observableUser.name = 'Nava Guller'; 21 | delete observableUser.age; 22 | observableUser.address.block = 49; 23 | observableUser.friends = []; 24 | observableUser.friends.push({ 25 | name: 'Aya', 26 | age: 2 27 | }, { 28 | name: 'Uria', 29 | age: 10 30 | }, { 31 | name: 'Alice', 32 | age: 12 33 | }); 34 | 35 | // when loggin the Observable, you'll see many internal properties 36 | console.log(observableUser); 37 | 38 | // ...but when picking the iterable keys there will only be the relevant ones 39 | console.log(Object.keys(observableUser).join(', ')); 40 | 41 | // ...and so will be the case if serializing the Observable 42 | console.log(JSON.stringify(observableUser)); -------------------------------------------------------------------------------- /docs/snippets/object-observer-ctor-example.js: -------------------------------------------------------------------------------- 1 | import { ObjectObserver } from 'https://libs.gullerya.com/object-observer/4.2.2/object-observer.js'; 2 | 3 | // raw data created in client or fetched from server 4 | let user = { 5 | name: 'Nava', 6 | age: 8, 7 | address: { 8 | street: 'Noway', 9 | block: 50, 10 | city: 'Sin City', 11 | country: 'Neverland' 12 | } 13 | }; 14 | 15 | // setup observation 16 | const userObserver = new ObjectObserver(changes => console.log(changes)); 17 | const observableUser = userObserver.observe(user); 18 | 19 | // now lets play with some changes to see the results (see the console log in dev tools) 20 | observableUser.name = 'Nava Guller'; 21 | delete observableUser.age; 22 | observableUser.address.block = 49; 23 | observableUser.friends = []; 24 | observableUser.friends.push({ 25 | name: 'Aya', 26 | age: 2 27 | }, { 28 | name: 'Uria', 29 | age: 10 30 | }, { 31 | name: 'Alice', 32 | age: 12 33 | }); 34 | 35 | // when loggin the Observable, you'll see many internal properties 36 | console.log(observableUser); 37 | 38 | // ...but when picking the iterable keys there will only be the relevant ones 39 | console.log(Object.keys(observableUser).join(', ')); 40 | 41 | // ...and so will be the case if serializing the Observable 42 | console.log(JSON.stringify(observableUser)); -------------------------------------------------------------------------------- /tests/revokation.js: -------------------------------------------------------------------------------- 1 | import { test } from '@gullerya/just-test'; 2 | import { assert } from '@gullerya/just-test/assert'; 3 | import { Observable } from '../src/object-observer.js'; 4 | 5 | test('test revokation of replaced objects - simple set', () => { 6 | const og = Observable.from({ 7 | a: { 8 | b: { 9 | prop: 'text' 10 | }, 11 | prop: 'text' 12 | } 13 | }); 14 | let events = []; 15 | 16 | Observable.observe(og, changes => events.push(...changes)); 17 | 18 | og.a = og.a.b; 19 | assert.strictEqual(og.a.prop, 'text'); 20 | assert.equal(events.length, 1); 21 | assert.deepStrictEqual(events[0], { type: 'update', path: ['a'], value: { prop: 'text' }, oldValue: { b: { prop: 'text' }, prop: 'text' }, object: og }); 22 | }); 23 | 24 | test('test revokation of replaced objects - splice in array', () => { 25 | const og = Observable.from([ 26 | { 27 | child: { 28 | prop: 'text' 29 | }, 30 | prop: 'text' 31 | } 32 | ]); 33 | let events = []; 34 | 35 | Observable.observe(og, changes => events.push(...changes)); 36 | 37 | og.splice(0, 1, og[0].child); 38 | assert.strictEqual(og[0].prop, 'text'); 39 | assert.strictEqual(events.length, 1); 40 | assert.deepStrictEqual(events[0], { type: 'update', path: [0], value: { prop: 'text' }, oldValue: { child: { prop: 'text' }, prop: 'text' }, object: og }); 41 | }); -------------------------------------------------------------------------------- /tests/reassignment-of-equals.js: -------------------------------------------------------------------------------- 1 | import { test } from '@gullerya/just-test'; 2 | import { assert } from '@gullerya/just-test/assert'; 3 | import { Observable } from '../src/object-observer.js'; 4 | 5 | test('boolean', () => { 6 | const oo = Observable.from({ p: true }); 7 | let changes = null; 8 | Observable.observe(oo, cs => { changes = cs; }); 9 | 10 | oo.p = true; 11 | assert.equal(changes, null); 12 | }); 13 | 14 | test('number', () => { 15 | const oo = Observable.from({ p: 6 }); 16 | let changes = null; 17 | Observable.observe(oo, cs => { changes = cs; }); 18 | 19 | oo.p = 6; 20 | assert.equal(changes, null); 21 | }); 22 | 23 | test('string', () => { 24 | const oo = Observable.from({ p: 'text' }); 25 | let changes = null; 26 | Observable.observe(oo, cs => { changes = cs; }); 27 | 28 | oo.p = 'text'; 29 | assert.equal(changes, null); 30 | }); 31 | 32 | test('function', () => { 33 | const 34 | f = function () { }, 35 | oo = Observable.from({ p: f }); 36 | let changes = null; 37 | Observable.observe(oo, cs => { changes = cs; }); 38 | 39 | oo.p = f; 40 | assert.equal(changes, null); 41 | }); 42 | 43 | test('Symbol', () => { 44 | const 45 | s = Symbol('some'), 46 | oo = Observable.from({ p: s }); 47 | let changes = null; 48 | Observable.observe(oo, cs => { changes = cs; }); 49 | 50 | oo.p = s; 51 | assert.equal(changes, null); 52 | }); -------------------------------------------------------------------------------- /tests/object-generics.js: -------------------------------------------------------------------------------- 1 | import { test } from '@gullerya/just-test'; 2 | import { assert } from '@gullerya/just-test/assert'; 3 | import { Observable } from '../src/object-observer.js'; 4 | 5 | test('Object.seal - further extensions should fail', () => { 6 | const oo = Observable.from({ propA: 'a', propB: 'b' }); 7 | Object.seal(oo); 8 | assert.throws(() => oo.propC = 'c'); 9 | }); 10 | 11 | test('Object.seal - props removal should fail', () => { 12 | const oo = Observable.from({ propA: 'a', propB: 'b' }); 13 | Object.seal(oo); 14 | assert.throws(() => delete oo.propA); 15 | }); 16 | 17 | test('Object.seal - modifications allowed', () => { 18 | const oo = Observable.from({ propA: 'a', propB: 'b' }); 19 | let events = 0; 20 | Object.seal(oo); 21 | Observable.observe(oo, changes => { events += changes.length; }); 22 | oo.propA = 'A'; 23 | assert.strictEqual(events, 1); 24 | }); 25 | 26 | test('Object.seal - nested - further extensions should fail', () => { 27 | const oo = Observable.from({ nested: { propA: 'a', propB: 'b' } }); 28 | Object.seal(oo.nested); 29 | assert.throws(() => oo.nested.propC = 'c'); 30 | }); 31 | 32 | test('Object.seal - nested - props removal should fail', () => { 33 | const oo = Observable.from({ nested: { propA: 'a', propB: 'b' } }); 34 | Object.seal(oo.nested); 35 | assert.throws(() => delete oo.nested.propA); 36 | }); 37 | 38 | test('Object.seal - nested - modifications allowed', () => { 39 | const oo = Observable.from({ nested: { propA: 'a', propB: 'b' } }); 40 | let events = 0; 41 | Object.seal(oo.nested); 42 | Observable.observe(oo, changes => { events += changes.length; }); 43 | oo.nested.propA = 'A'; 44 | assert.strictEqual(events, 1); 45 | }); -------------------------------------------------------------------------------- /tests/cross-instance.js: -------------------------------------------------------------------------------- 1 | import { test } from '@gullerya/just-test'; 2 | import { assert } from '@gullerya/just-test/assert'; 3 | import { Observable as O1, ObjectObserver as OO1 } from '../src/object-observer.js?1'; 4 | import { Observable as O2, ObjectObserver as OO2 } from '../src/object-observer.js?2'; 5 | 6 | test('Observable.isObservable interoperable', () => { 7 | assert.notEqual(O1, O2); 8 | const obsbl1 = O1.from({}); 9 | assert.isTrue(O1.isObservable(obsbl1)); 10 | assert.isTrue(O2.isObservable(obsbl1)); 11 | const obsbl2 = O2.from({}); 12 | assert.isTrue(O2.isObservable(obsbl2)); 13 | assert.isTrue(O1.isObservable(obsbl2)); 14 | }); 15 | 16 | test('Observable.from interoperable', () => { 17 | const obsbl1 = O1.from({}); 18 | const obsbl2 = O2.from(obsbl1); 19 | assert.equal(obsbl1, obsbl2); 20 | 21 | const obsbl3 = O2.from({}); 22 | const obsbl4 = O1.from(obsbl3); 23 | assert.equal(obsbl3, obsbl4); 24 | }); 25 | 26 | test('callbacks are interoperable', () => { 27 | const obsbl1 = O1.from({}); 28 | const obsbl2 = O2.from(obsbl1); 29 | assert.equal(obsbl1, obsbl2); 30 | 31 | let count = 0; 32 | O1.observe(obsbl1, es => count += es.length); 33 | O2.observe(obsbl2, es => count += es.length); 34 | 35 | obsbl1.some = 'thing'; 36 | obsbl2.some = 'else'; 37 | 38 | assert.equal(count, 4); 39 | }); 40 | 41 | test('ObjectObserver interoperable', () => { 42 | assert.notEqual(OO1, OO2); 43 | let count = 0; 44 | const oo1 = new OO1(es => count += es.length); 45 | const oo2 = new OO2(es => count += es.length); 46 | 47 | const o1 = oo1.observe({}); 48 | const o2 = oo2.observe(o1); 49 | assert.equal(o1, o2); 50 | 51 | o1.some = 'thing'; 52 | o2.some = 'else'; 53 | 54 | assert.equal(count, 4); 55 | }); -------------------------------------------------------------------------------- /tests/object-observer-performance-sync.js: -------------------------------------------------------------------------------- 1 | import { test } from '@gullerya/just-test'; 2 | 3 | const TOLERANCE_MULTIPLIER = 5; 4 | 5 | const 6 | CREATE_ITERATIONS = 100000, 7 | MUTATE_ITERATIONS = 1000000; 8 | 9 | test(`creating ${CREATE_ITERATIONS} observables, ${MUTATE_ITERATIONS} deep (x3) mutations`, { 10 | // skip: true, 11 | ttl: 15000 12 | }, async () => { 13 | await executeInWorker('perf-sync-test-a.js', { 14 | TOLERANCE_MULTIPLIER: TOLERANCE_MULTIPLIER, 15 | CREATE_ITERATIONS: CREATE_ITERATIONS, 16 | MUTATE_ITERATIONS: MUTATE_ITERATIONS, 17 | OBJECT_CREATION_TRSHLD: 0.005, 18 | PRIMITIVE_DEEP_MUTATION_TRSHLD: 0.0003, 19 | PRIMITIVE_DEEP_ADDITION_TRSHLD: 0.0006, 20 | PRIMITIVE_DEEP_DELETION_TRSHLD: 0.0006 21 | }); 22 | }); 23 | 24 | const ARRAY_ITERATIONS = 100000; 25 | 26 | test(`push ${ARRAY_ITERATIONS} observables to an array, mutate them and pop them back`, { 27 | // skip: true, 28 | ttl: 15000 29 | }, async () => { 30 | await executeInWorker('perf-sync-test-b.js', { 31 | TOLERANCE_MULTIPLIER: TOLERANCE_MULTIPLIER, 32 | ARRAY_ITERATIONS: ARRAY_ITERATIONS, 33 | ARRAY_PUSH_TRSHLD: 0.005, 34 | ARRAY_MUTATION_TRSHLD: 0.005, 35 | ARRAY_POP_TRSHLD: 0.001 36 | }); 37 | }); 38 | 39 | async function executeInWorker(testUrl, testParams) { 40 | // return (await import(testUrl)).default(testParams); 41 | 42 | let CrossPlatformWorker = globalThis.Worker; 43 | if (!CrossPlatformWorker) { 44 | CrossPlatformWorker = (await import('node:worker_threads')).default.Worker; 45 | } 46 | 47 | return new Promise((resolve, reject) => { 48 | const w = new CrossPlatformWorker('./tests/workers/perf-worker.js'); 49 | w.on('message', message => { 50 | w.terminate(); 51 | if (message?.error) { 52 | reject(new Error(message.error.message)); 53 | } else { 54 | resolve(); 55 | } 56 | }); 57 | w.on('error', error => { 58 | w.terminate(); 59 | reject(error); 60 | }); 61 | w.postMessage({ testUrl: testUrl, testParams: testParams }); 62 | }); 63 | } -------------------------------------------------------------------------------- /tests/object-observer-performance-async.js: -------------------------------------------------------------------------------- 1 | import { test } from '@gullerya/just-test'; 2 | 3 | const TOLERANCE_MULTIPLIER = 5; 4 | 5 | const 6 | CREATE_ITERATIONS = 100000, 7 | MUTATE_ITERATIONS = 1000000; 8 | 9 | test(`creating ${CREATE_ITERATIONS} observables, ${MUTATE_ITERATIONS} deep (x3) mutations`, { 10 | // skip: true, 11 | ttl: 15000 12 | }, async () => { 13 | await executeInWorker('perf-async-test-a.js', { 14 | TOLERANCE_MULTIPLIER: TOLERANCE_MULTIPLIER, 15 | CREATE_ITERATIONS: CREATE_ITERATIONS, 16 | MUTATE_ITERATIONS: MUTATE_ITERATIONS, 17 | OBJECT_CREATION_TRSHLD: 0.005, 18 | PRIMITIVE_DEEP_MUTATION_TRSHLD: 0.0006, 19 | PRIMITIVE_DEEP_ADDITION_TRSHLD: 0.001, 20 | PRIMITIVE_DEEP_DELETION_TRSHLD: 0.001 21 | }); 22 | }); 23 | 24 | const ARRAY_ITERATIONS = 100000; 25 | 26 | test(`push ${ARRAY_ITERATIONS} observables to an array, mutate them and pop them back`, { 27 | // skip: true, 28 | ttl: 15000 29 | }, async () => { 30 | await executeInWorker('perf-async-test-b.js', { 31 | TOLERANCE_MULTIPLIER: TOLERANCE_MULTIPLIER, 32 | ARRAY_ITERATIONS: ARRAY_ITERATIONS, 33 | ARRAY_PUSH_TRSHLD: 0.005, 34 | ARRAY_MUTATION_TRSHLD: 0.005, 35 | ARRAY_POP_TRSHLD: 0.001 36 | }); 37 | }); 38 | 39 | async function executeInWorker(testUrl, testParams) { 40 | // return (await import(testUrl)).default(testParams); 41 | 42 | let CrossPlatformWorker = globalThis.Worker; 43 | if (!CrossPlatformWorker) { 44 | CrossPlatformWorker = (await import('node:worker_threads')).default.Worker; 45 | } 46 | 47 | return new Promise((resolve, reject) => { 48 | const w = new CrossPlatformWorker('./tests/workers/perf-worker.js'); 49 | w.on('message', message => { 50 | w.terminate(); 51 | if (message?.error) { 52 | reject(new Error(message.error.message)); 53 | } else { 54 | resolve(); 55 | } 56 | }); 57 | w.on('error', error => { 58 | w.terminate(); 59 | reject(error); 60 | }); 61 | w.postMessage({ testUrl: testUrl, testParams: testParams }); 62 | }); 63 | } 64 | -------------------------------------------------------------------------------- /tests/object-observer-objects-circular.js: -------------------------------------------------------------------------------- 1 | import { test } from '@gullerya/just-test'; 2 | import { assert } from '@gullerya/just-test/assert'; 3 | import { Observable } from '../src/object-observer.js'; 4 | 5 | test('subgraph object pointing to the top parent', () => { 6 | const o = { prop: 'text' }; 7 | o.child = o; 8 | const oo = Observable.from(o); 9 | const changes = []; 10 | Observable.observe(oo, cs => { 11 | changes.push(...cs); 12 | }); 13 | oo.prop = 'else'; 14 | 15 | assert.strictEqual(oo.child, null); 16 | assert.strictEqual(changes.length, 1); 17 | assert.deepEqual(changes[0], { 18 | type: 'update', 19 | path: ['prop'], 20 | value: 'else', 21 | oldValue: 'text', 22 | object: oo 23 | }); 24 | }); 25 | 26 | test('subgraph object pointing to parent in the graph', () => { 27 | const o = { gen1: { gen2: { prop: 'text' } } }; 28 | o.gen1.gen2.child = o.gen1; 29 | const oo = Observable.from(o); 30 | const changes = []; 31 | Observable.observe(oo, cs => { 32 | changes.push(...cs); 33 | }); 34 | oo.gen1.gen2.prop = 'else'; 35 | 36 | assert.strictEqual(oo.gen1.gen2.child, null); 37 | assert.strictEqual(changes.length, 1); 38 | assert.deepEqual(changes[0], { 39 | type: 'update', 40 | path: ['gen1', 'gen2', 'prop'], 41 | value: 'else', 42 | oldValue: 'text', 43 | object: oo.gen1.gen2 44 | }); 45 | }); 46 | 47 | test('circular object assigned to an existing observable graph (object)', () => { 48 | const o = { gen1: { gen2: { prop: 'text' } } }; 49 | o.gen1.gen2.child = o.gen1; 50 | 51 | const oo = Observable.from({}); 52 | oo.newbie = o; 53 | 54 | const changes = []; 55 | Observable.observe(oo, cs => { 56 | changes.push(...cs); 57 | }); 58 | oo.newbie.gen1.gen2.prop = 'else'; 59 | 60 | assert.strictEqual(oo.newbie.gen1.gen2.child, null); 61 | assert.strictEqual(changes.length, 1); 62 | assert.deepEqual(changes[0], { 63 | type: 'update', 64 | path: ['newbie', 'gen1', 'gen2', 'prop'], 65 | value: 'else', 66 | oldValue: 'text', 67 | object: oo.newbie.gen1.gen2 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /docs/dom-like-api.md: -------------------------------------------------------------------------------- 1 | # 'DOM-like' API 2 | 3 | Starting from version 4.2.0 `object-observer` provides additional 'DOM-like' API flavor. 4 | To be sure, this is a thin layer over an exising `Observable` API, so do make sure to get accustomized with [this](observable.md). 5 | 6 | While `Observable` API is more __data__ centric, the present API can be seen as more __logic__ centric one. 7 | 8 | ## Use cases and basic example 9 | 10 | This API flavor suites well, when there is a generic logic to run on any changes of multiple unrelated targets. 11 | 12 | In this case you'd create an `ObjectObserver` instance once: 13 | ```js 14 | const loggingObserver = new ObjectObserver(changes => { 15 | changes.forEach( ... ); 16 | }); 17 | ``` 18 | 19 | and then observe with it subjects of observation: 20 | ```js 21 | const observedUser = loggingObserver.observe(user); 22 | // or 23 | const observedSettings = loggingObserver.observe(settings); 24 | ``` 25 | 26 | Attention: for `ObjectObserver` to be able to react on changes, mutations MUST be performed on the `Observable` objects returned from the `observe` method. 27 | Therefore, these objects should become a primary operational 'model' in your logic. 28 | 29 | ## API 30 | 31 | ```js 32 | import { ObjectObserver } from 'object-observer.min.js'; 33 | ``` 34 | 35 | `ObjectObserver` is class. Construct an instance as following: 36 | 37 | `const oo = new ObjectObserver(callback);` 38 | 39 | The `callback` is a function with the following signature: 40 | 41 | `(changes: Change[]): void`. 42 | 43 | ### `ObjectObserver` instance methods 44 | 45 | | Method | Signature | Returns | Description | 46 | |--------------|-------------------------|--------------|------------| 47 | | `observe` | `(subject: object)` | `Observable` | create new `Observable` from the given subject (unless it is already `Observable`) and start observing it | 48 | | `unobserve` | `(subject: Observable)` | | stop observing the specified `Observable` subject | 49 | | `disconnect` | `()` | | stop observing __all__ observed subjects | -------------------------------------------------------------------------------- /tests/object-observer-native-objects-to-skip.js: -------------------------------------------------------------------------------- 1 | import { test } from '@gullerya/just-test'; 2 | import { assert } from '@gullerya/just-test/assert'; 3 | import { Observable } from '../src/object-observer.js'; 4 | 5 | test('creating observable from non-observable should throw an error', () => { 6 | const objectsToTest = [ 7 | new Date() 8 | ]; 9 | 10 | for (const one of objectsToTest) { 11 | assert.throws(() => { 12 | Observable.from(one); 13 | }, 'found to be one of a non-observable types'); 14 | } 15 | 16 | const o = Observable.from(objectsToTest); 17 | assert.isTrue(objectsToTest.every(one => o.some(oo => oo === one))); 18 | }); 19 | 20 | test('non-observable in an object subgraph should stay unchanged', () => { 21 | const o = { 22 | data: new Date(), 23 | object: {} 24 | }; 25 | const po = Observable.from(o); 26 | 27 | Object.keys(o).forEach(key => { 28 | if (key === 'object') { 29 | assert.notStrictEqual(o[key], po[key]); 30 | } else { 31 | assert.strictEqual(o[key], po[key]); 32 | } 33 | }); 34 | }); 35 | 36 | test('non-observable in an array subgraph should stay unchanged', () => { 37 | const a = [ 38 | {}, 39 | new Date() 40 | ]; 41 | const o = Observable.from(a); 42 | 43 | a.forEach((elem, index) => { 44 | if (index === 0) { 45 | assert.notStrictEqual(a[index], o[index]); 46 | } else { 47 | assert.strictEqual(a[index], o[index]); 48 | } 49 | }); 50 | }); 51 | 52 | test('non-observable should not throw when nullified', () => { 53 | const oo = Observable.from({ 54 | date: new Date() 55 | }); 56 | 57 | Observable.observe(oo, () => { }); 58 | oo.date = null; 59 | }); 60 | 61 | test('non-observable should be handled correctly when replaced', () => { 62 | const oo = Observable.from({ 63 | date: new Date() 64 | }); 65 | 66 | assert.isTrue(oo.date instanceof Date); 67 | 68 | oo.date = new Date(2020, 10, 5); 69 | 70 | assert.isTrue(oo.date instanceof Date); 71 | }); 72 | 73 | test('non-observable deviation should be handled correctly when replaced', () => { 74 | const 75 | o = { 76 | date: new Date() 77 | }, 78 | oo = Observable.from(o); 79 | 80 | assert.strictEqual(oo.date, o.date); 81 | 82 | oo.date = new Date(); 83 | 84 | assert.isTrue(oo.date instanceof Date); 85 | }); 86 | 87 | test('non-observable should be handled correctly when deleted', () => { 88 | const oo = Observable.from({ 89 | date: new Date() 90 | }); 91 | 92 | Observable.observe(oo, () => { }); 93 | delete oo.date; 94 | }); 95 | -------------------------------------------------------------------------------- /tests/object-observer-objects-async.js: -------------------------------------------------------------------------------- 1 | import { test } from '@gullerya/just-test'; 2 | import { assert } from '@gullerya/just-test/assert'; 3 | import { waitNextTask } from '@gullerya/just-test/timing'; 4 | import { Observable } from '../src/object-observer.js'; 5 | 6 | test('multiple continuous mutations', async () => { 7 | const 8 | observable = Observable.from({}, { async: true }), 9 | events = []; 10 | let callbacks = 0; 11 | Observable.observe(observable, changes => { 12 | callbacks++; 13 | events.push.apply(events, changes); 14 | }); 15 | 16 | observable.a = 'some'; 17 | observable.b = 2; 18 | observable.a = 'else'; 19 | delete observable.b; 20 | 21 | await waitNextTask(); 22 | 23 | assert.strictEqual(callbacks, 1); 24 | assert.strictEqual(events.length, 4); 25 | }); 26 | 27 | test('multiple continuous mutations is split bursts', async () => { 28 | const 29 | observable = Observable.from({}, { async: true }), 30 | events = []; 31 | let callbacks = 0; 32 | Observable.observe(observable, changes => { 33 | callbacks++; 34 | events.push.apply(events, changes); 35 | }); 36 | 37 | // first burst 38 | observable.a = 1; 39 | observable.b = 2; 40 | await waitNextTask(); 41 | 42 | assert.strictEqual(callbacks, 1); 43 | assert.strictEqual(events.length, 2); 44 | 45 | callbacks = 0; 46 | events.splice(0); 47 | 48 | // second burst 49 | observable.a = 3; 50 | observable.b = 4; 51 | await waitNextTask(); 52 | 53 | assert.strictEqual(callbacks, 1); 54 | assert.strictEqual(events.length, 2); 55 | }); 56 | 57 | test('Object.assign with multiple properties', async () => { 58 | const 59 | observable = Observable.from({}, { async: true }), 60 | newData = { a: 1, b: 2, c: 3 }, 61 | events = []; 62 | let callbacks = 0; 63 | Observable.observe(observable, changes => { 64 | callbacks++; 65 | events.push.apply(events, changes); 66 | }); 67 | 68 | Object.assign(observable, newData); 69 | 70 | await waitNextTask(); 71 | 72 | assert.strictEqual(callbacks, 1); 73 | assert.strictEqual(events.length, 3); 74 | }); 75 | 76 | test('Object.assign with multiple properties + more changes', async () => { 77 | const 78 | observable = Observable.from({}, { async: true }), 79 | newData = { a: 1, b: 2, c: 3 }, 80 | events = []; 81 | let callbacks = 0; 82 | Observable.observe(observable, changes => { 83 | callbacks++; 84 | events.push.apply(events, changes); 85 | }); 86 | 87 | Object.assign(observable, newData); 88 | observable.a = 4; 89 | 90 | await waitNextTask(); 91 | 92 | assert.strictEqual(callbacks, 1); 93 | assert.strictEqual(events.length, 4); 94 | }); -------------------------------------------------------------------------------- /tests/api-base.js: -------------------------------------------------------------------------------- 1 | import { test } from '@gullerya/just-test'; 2 | import { assert } from '@gullerya/just-test/assert'; 3 | import { Observable } from '../src/object-observer.js'; 4 | 5 | test('ensure Observable object has defined APIs', () => { 6 | assert.equal(typeof Observable, 'object'); 7 | assert.equal(typeof Observable.from, 'function'); 8 | assert.equal(typeof Observable.isObservable, 'function'); 9 | assert.equal(typeof Observable.observe, 'function'); 10 | assert.equal(typeof Observable.unobserve, 'function'); 11 | assert.equal(Object.keys(Observable).length, 4); 12 | }); 13 | 14 | test('ensure Observable object is frozen', () => { 15 | assert.throws(() => Observable.some = 'prop'); 16 | }); 17 | 18 | test('negative tests - invalid parameters', () => { 19 | assert.throws(() => Observable.from(undefined), 'observable MAY ONLY be created from'); 20 | 21 | assert.throws(() => Observable.from(null), 'observable MAY ONLY be created from'); 22 | 23 | assert.throws(() => Observable.from(true), 'observable MAY ONLY be created from'); 24 | 25 | assert.throws(() => Observable.from(1), 'observable MAY ONLY be created from'); 26 | 27 | assert.throws(() => Observable.from('string'), 'observable MAY ONLY be created from'); 28 | 29 | assert.throws(() => Observable.from(() => { }), 'observable MAY ONLY be created from'); 30 | }); 31 | 32 | test('isObservable tests', () => { 33 | assert.isFalse(Observable.isObservable('some')); 34 | assert.isFalse(Observable.isObservable(null)); 35 | assert.isFalse(Observable.isObservable({})); 36 | assert.isTrue(Observable.isObservable(Observable.from({}))); 37 | 38 | const oo = Observable.from({ nested: {} }); 39 | assert.isTrue(Observable.isObservable(oo)); 40 | }); 41 | 42 | test('test observable APIs - ensure APIs are not enumerables', () => { 43 | const oo = Observable.from({}); 44 | 45 | assert.equal(Object.keys(oo).length, 0); 46 | 47 | const aa = Observable.from([]); 48 | 49 | assert.equal(Object.keys(aa).length, 0); 50 | }); 51 | 52 | test('negative - invalid options - not an object', () => { 53 | assert.throws(() => Observable.from({}, 4), 'Observable options if/when provided, MAY only be an object'); 54 | }); 55 | 56 | test('negative - invalid options - wrong param', () => { 57 | assert.throws(() => Observable.from({}, { invalid: 'key' }), 'is/are not a valid Observable option/s'); 58 | }); 59 | 60 | test('negative observe - invalid observable', () => { 61 | assert.throws(() => Observable.observe({}), 'invalid observable parameter'); 62 | }); 63 | 64 | test('negative unobserve - invalid observable', () => { 65 | assert.throws(() => Observable.unobserve({}), 'invalid observable parameter'); 66 | }); -------------------------------------------------------------------------------- /tests/object-observer-subgraphs.js: -------------------------------------------------------------------------------- 1 | import { test } from '@gullerya/just-test'; 2 | import { assert } from '@gullerya/just-test/assert'; 3 | import { Observable } from '../src/object-observer.js'; 4 | 5 | test('inner object from observable should fire events as usual', () => { 6 | const 7 | oo = Observable.from({ inner: { prop: 'more' } }), 8 | iop = oo.inner, 9 | events = [], 10 | observer = function (changes) { 11 | events.push.apply(events, changes); 12 | }; 13 | 14 | Observable.observe(oo, observer); 15 | iop.prop = 'else'; 16 | iop.new = 'prop'; 17 | 18 | assert.strictEqual(events.length, 2); 19 | assert.deepStrictEqual(events[0], { type: 'update', path: ['inner', 'prop'], value: 'else', oldValue: 'more', object: iop }); 20 | assert.deepStrictEqual(events[1], { type: 'insert', path: ['inner', 'new'], value: 'prop', oldValue: undefined, object: iop }); 21 | }); 22 | 23 | test('removal (detaching) of inner object from observable should detach its events', () => { 24 | const 25 | oo = Observable.from({ inner: { prop: 'more' } }), 26 | iop = oo.inner, 27 | observer = function () { 28 | cntr++; 29 | }; 30 | let cntr = 0; 31 | 32 | Observable.observe(oo, observer); 33 | iop.prop = 'text'; 34 | assert.strictEqual(cntr, 1); 35 | 36 | oo.inner = null; 37 | cntr = 0; 38 | iop.prop = 'again'; 39 | assert.strictEqual(cntr, 0); 40 | }); 41 | 42 | test('replacement of inner object from observable should return non-proxified original object', () => { 43 | const oo = Observable.from({ inner: { prop: 'more', nested: { text: 'text' } } }); 44 | let events = []; 45 | Observable.observe(oo, changes => Array.prototype.push.apply(events, changes)); 46 | 47 | oo.inner.prop = 'some'; 48 | assert.strictEqual(events.length, 1); 49 | assert.deepStrictEqual(events[0], { type: 'update', path: ['inner', 'prop'], value: 'some', oldValue: 'more', object: oo.inner }); 50 | events = []; 51 | 52 | oo.inner = { p: 'back' }; 53 | assert.strictEqual(events.length, 1); 54 | assert.deepStrictEqual(events[0], { type: 'update', path: ['inner'], value: { p: 'back' }, oldValue: { prop: 'some', nested: { text: 'text' } }, object: oo }); 55 | }); 56 | 57 | test('Object.assign on observable should raise an event/s of update with non-proxified original object', () => { 58 | const 59 | observable = Observable.from({ b: { b1: 'x', b2: 'y' } }), 60 | newData = { b: { b1: 'z' } }, 61 | events = []; 62 | Observable.observe(observable, changes => events.push.apply(events, changes)); 63 | 64 | Object.assign(observable, newData); 65 | 66 | assert.strictEqual(events.length, 1); 67 | assert.deepStrictEqual(events[0], { type: 'update', path: ['b'], value: { b1: 'z' }, oldValue: { b1: 'x', b2: 'y' }, object: observable }); 68 | }); -------------------------------------------------------------------------------- /docs/filter-paths.md: -------------------------------------------------------------------------------- 1 | # Filter paths options 2 | 3 | `Observable.observe(...)` allows `options` parameter, third one, optional. 4 | 5 | Some of the options are filtering ones, allowing to specify the changes of interest from within the observable graph. Here is a detailed description of those options. 6 | 7 | ## __`pathsFrom`__ 8 | 9 | Value expected to be a non-empty string representing a path, any changes of which and deeper will be delivered to the observer. 10 | > This option MAY NOT be used together with `path` option. 11 | 12 | 13 | 14 | 17 | 27 | 28 |
15 | 16 | 18 | Given, that we have subscribed for the changes via: 19 |
Observable.observe(o, callback, { pathsFrom: 'address' });
20 | Following mutations will be delivered to the callback: 21 |
o.address.street.apt = 5;
22 | o.address.city = 'DreamCity';
23 | o.address = {};
24 | Following mutations will not be delivered to the callback: 25 |
o.lastName = 'Joker';
26 |
29 | 30 | ## __`pathsOf`__ 31 | 32 | Value expected to be a string, which MAY be empty, representing a path. Changes to direct properties of which will be notified. 33 | 34 | 35 | 36 | 39 | 50 | 51 |
37 | 38 | 40 | Given, that we have subscribed for the changes via: 41 |
Observable.observe(o, callback, { pathsOf: 'address' });
42 | Following mutations will be delivered to the callback: 43 |
o.address.street = {};
44 | o.address.city = 'DreamCity';
45 | Following mutations will not be delivered to the callback: 46 |
o.lastName = 'Joker';
47 | o.address = {};
48 | o.address.street.apt = 5;
49 |
52 | 53 | ## __`path`__ 54 | 55 | Value expected to be a non-empty string, representing a specific path to observe. Only a changes of this exact path will be notified. 56 | 57 | 58 | 59 | 62 | 72 | 73 |
60 | 61 | 63 | Given, that we have subscribed for the changes via: 64 |
Observable.observe(o, callback, { path: 'address.street' });
65 | Following mutations will be delivered to the callback: 66 |
o.address.street = {};
67 | Following mutations will not be delivered to the callback: 68 |
o.lastName = 'Joker';
69 | o.address = {};
70 | o.address.street.apt = 5;
71 |
-------------------------------------------------------------------------------- /docs/sync-async.md: -------------------------------------------------------------------------------- 1 | # Sync/Async changes delivery 2 | 3 | ## Introduction 4 | 5 | Beginning from version `3.2.0` it is possible to control whether the changes to be delivered in a sync or async fashion. 6 | 7 | The opt-in mechanism works on the level of `Observable`, via the options passed to the static `Observable.from` method during the creation and affects all callbacks/observers that will be listening on the observable instance. 8 | 9 | Asynchronous behaviour implemented via `queueMicrotask` mechanism, mimicing a similar native implementations like `Promise`, `MutationObserver` etc. 10 | 11 | Which one to use? I suggest to go first with a default __sync__ flavor and switch to the __async__ only when it is proven to be needed, for example batch delivery of changes of `Object.assign` (see below). 12 | 13 | ## Sync 14 | 15 | Default behaviour is to deliver changes __synchronously__: 16 | ``` 17 | const o = Observable.from({}); 18 | Observable.observe(o, changes => 19 | changes.forEach(change => console.log(change.value)) 20 | ); 21 | 22 | o.propA = 1; 23 | console.log(2); 24 | o.propB = 3; 25 | 26 | // console output: 27 | // 1 28 | // 2 29 | // 3 30 | ``` 31 | 32 | Here is the place to mention somewhat unexpected outcome of this flavor in case of `Object.assign` usage: 33 | 34 | ``` 35 | const o = Observable.from({}); 36 | let callbacksCount = 0; 37 | Observable.observe(o, () => callbacksCount++); 38 | 39 | Object.assign(o, { a: 1, b: 2, c: 3 }); 40 | console.log(callbacksCount); 41 | 42 | // console output: 43 | // 3 44 | ``` 45 | 46 | While one would expect to have a single callback with 3 changes, in fact we've got 3 callback, each with a single change. `Object.assign` does a full assignment cycle per property, including passing via the `Proxy` traps, which causes this single operation to appear as 3 separate assignements. 47 | 48 | ## Async 49 | 50 | One may opt-in an __asynchronous__ changes delivery: 51 | ``` 52 | const o = Observable.from({}, { async: true }); 53 | Observable.observe(o, changes => 54 | changes.forEach(change => console.log(change.value)) 55 | ); 56 | 57 | o.propA = 3; 58 | console.log(1); 59 | o.propB = 4; 60 | console.log(2); 61 | 62 | // console output: 63 | // 1 64 | // 2 65 | // 3 66 | // 4 67 | ``` 68 | 69 | In this case `Object.assign` will behave correctly: 70 | 71 | ``` 72 | const o = Observable.from({}); 73 | let callbacksCount = 0; 74 | Observable.observe(o, () => callbacksCount++); 75 | 76 | Object.assign(o, { a: 1, b: 2, c: 3 }); 77 | 78 | queueMicrotask(() => 79 | console.log(callbacksCount) 80 | ); 81 | 82 | // console output: 83 | // 1 84 | ``` 85 | 86 | Now, due to postponing changes delivery to the end of the currently running task, all 3 changes are delivered in a single callback. 87 | 88 | Pay attention, that because of the asynchronicity, we also need to postpone the inspection of the effect of the callback/s. -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@gullerya/object-observer", 3 | "version": "6.1.4", 4 | "description": "object-observer utility provides simple means to (deeply) observe specified object/array changes; implemented via native Proxy; changes delivered in a synchronous way", 5 | "keywords": [ 6 | "object", 7 | "array", 8 | "observe", 9 | "observer", 10 | "object observe", 11 | "object.observe", 12 | "observable", 13 | "changes", 14 | "deep", 15 | "tree", 16 | "graph", 17 | "javascript", 18 | "proxy", 19 | "C", 20 | "J", 21 | "G", 22 | "Y" 23 | ], 24 | "author": { 25 | "name": "Yuri Guller", 26 | "email": "gullerya@gmail.com" 27 | }, 28 | "homepage": "https://github.com/gullerya/object-observer", 29 | "repository": { 30 | "type": "git", 31 | "url": "https://github.com/gullerya/object-observer" 32 | }, 33 | "bugs": { 34 | "url": "https://github.com/gullerya/object-observer/issues", 35 | "email": "gullerya@gmail.com" 36 | }, 37 | "license": "ISC", 38 | "funding": [ 39 | { 40 | "url": "https://paypal.me/gullerya?locale.x=en_US" 41 | }, 42 | { 43 | "url": "https://tidelift.com/funding/github/npm/object-observer" 44 | } 45 | ], 46 | "type": "module", 47 | "main": "./dist/object-observer.min.js", 48 | "module": "./dist/object-observer.min.js", 49 | "browser": "./dist/object-observer.min.js", 50 | "types": "./dist/object-observer.d.ts", 51 | "exports": { 52 | ".": { 53 | "types": "./dist/object-observer.d.ts", 54 | "import": "./dist/object-observer.min.js", 55 | "require": "./dist/cjs/object-observer.min.cjs" 56 | } 57 | }, 58 | "files": [ 59 | "dist", 60 | "sri.json" 61 | ], 62 | "scripts": { 63 | "build": "node ./ci/tools/build-utils.js", 64 | "lint": "eslint -c ./ci/eslint.config.mjs ./src/*.js ./tests/*.js ./ci/**/*.js", 65 | "test": "node node_modules/@gullerya/just-test/bin/local-runner.js config_file=./tests/configs/tests-config-ci-node.json", 66 | "test:chromium": "node node_modules/@gullerya/just-test/bin/local-runner.js config_file=./tests/configs/tests-config-ci-chromium.json", 67 | "test:firefox": "node node_modules/@gullerya/just-test/bin/local-runner.js config_file=./tests/configs/tests-config-ci-firefox.json", 68 | "test:webkit": "node node_modules/@gullerya/just-test/bin/local-runner.js config_file=./tests/configs/tests-config-ci-webkit.json", 69 | "test:nodejs": "node node_modules/@gullerya/just-test/bin/local-runner.js config_file=./tests/configs/tests-config-ci-node.json", 70 | "version": "npm run build && git add --all", 71 | "postversion": "git push && git push --tags" 72 | }, 73 | "devDependencies": { 74 | "@eslint/eslintrc": "^3.2.0", 75 | "@eslint/js": "^9.20.0", 76 | "@gullerya/just-test": "^4.0.6", 77 | "esbuild": "^0.25.0", 78 | "eslint": "^9.20.1", 79 | "globals": "^15.15.0" 80 | }, 81 | "publishConfig": { 82 | "access": "public" 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /ci/tools/build-utils.js: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import fs from 'node:fs/promises'; 3 | 4 | import esbuild from 'esbuild'; 5 | 6 | import { calcIntegrity } from './integrity-utils.js'; 7 | import * as stdout from './stdout.js'; 8 | 9 | const SRC_DIR = 'src'; 10 | const DIST_DIR = 'dist'; 11 | 12 | stdout.writeGreen('Starting the build...'); 13 | stdout.writeNewline(); 14 | stdout.writeNewline(); 15 | 16 | try { 17 | await cleanDistDir(); 18 | await buildESModule(); 19 | await buildCJSModule(); 20 | await buildCDNResources(); 21 | } catch (e) { 22 | console.error(e); 23 | } 24 | 25 | stdout.writeGreen('... build done'); 26 | stdout.writeNewline(); 27 | stdout.writeNewline(); 28 | 29 | async function cleanDistDir() { 30 | stdout.write(`\tcleaning "dist"...`); 31 | 32 | await fs.rm(DIST_DIR, { recursive: true, force: true }); 33 | await fs.mkdir(DIST_DIR); 34 | 35 | stdout.writeGreen('\tOK'); 36 | stdout.writeNewline(); 37 | } 38 | 39 | async function buildESModule() { 40 | stdout.write('\tbuilding ESM resources...'); 41 | 42 | await fs.copyFile(path.join(SRC_DIR, 'object-observer.d.ts'), path.join(DIST_DIR, 'object-observer.d.ts')); 43 | await fs.copyFile(path.join(SRC_DIR, 'object-observer.js'), path.join(DIST_DIR, 'object-observer.js')); 44 | await esbuild.build({ 45 | entryPoints: [path.join(DIST_DIR, 'object-observer.js')], 46 | outdir: DIST_DIR, 47 | minify: true, 48 | sourcemap: true, 49 | sourcesContent: false, 50 | outExtension: { '.js': '.min.js' } 51 | }); 52 | 53 | stdout.writeGreen('\tOK'); 54 | stdout.writeNewline(); 55 | } 56 | 57 | async function buildCJSModule() { 58 | stdout.write('\tbuilding CJS resources...'); 59 | 60 | const baseConfig = { 61 | entryPoints: [path.join(SRC_DIR, 'object-observer.js')], 62 | outdir: path.join(DIST_DIR, 'cjs'), 63 | format: 'cjs', 64 | outExtension: { '.js': '.cjs' } 65 | }; 66 | await esbuild.build(baseConfig); 67 | await esbuild.build({ 68 | ...baseConfig, 69 | entryPoints: [path.join(DIST_DIR, 'cjs', 'object-observer.cjs')], 70 | minify: true, 71 | sourcemap: true, 72 | sourcesContent: false, 73 | outExtension: { '.js': '.min.cjs' } 74 | }); 75 | 76 | stdout.writeGreen('\tOK'); 77 | stdout.writeNewline(); 78 | } 79 | 80 | async function buildCDNResources() { 81 | stdout.write('\tbuilding CDN resources...'); 82 | 83 | const CDN_DIR = path.join(DIST_DIR, 'cdn'); 84 | 85 | await fs.mkdir(CDN_DIR); 86 | 87 | const files = (await fs.readdir(DIST_DIR)) 88 | .filter(file => file.endsWith('.js') || file.endsWith('.map')); 89 | 90 | for (const file of files) { 91 | await fs.copyFile(path.join(DIST_DIR, file), path.join(CDN_DIR, file)); 92 | } 93 | 94 | const sriMap = await calcIntegrity(CDN_DIR); 95 | 96 | await fs.writeFile('sri.json', JSON.stringify(sriMap, null, '\t'), { encoding: 'utf-8' }); 97 | 98 | stdout.writeGreen('\tOK'); 99 | stdout.writeNewline(); 100 | } 101 | -------------------------------------------------------------------------------- /tests/listeners.js: -------------------------------------------------------------------------------- 1 | import { test } from '@gullerya/just-test'; 2 | import { assert } from '@gullerya/just-test/assert'; 3 | import { Observable } from '../src/object-observer.js'; 4 | 5 | test('test listeners invocation - single listener', () => { 6 | const oo = Observable.from({}); 7 | let events = []; 8 | 9 | Observable.observe(oo, changes => { events = events.concat(changes); }); 10 | 11 | oo.some = 'test'; 12 | oo.some = 'else'; 13 | delete oo.some; 14 | 15 | assert.strictEqual(events.length, 3); 16 | assert.deepStrictEqual(events[0], { 17 | type: 'insert', 18 | path: ['some'], 19 | oldValue: undefined, 20 | value: 'test', 21 | object: oo 22 | }); 23 | assert.deepStrictEqual(events[1], { 24 | type: 'update', 25 | path: ['some'], 26 | oldValue: 'test', 27 | value: 'else', 28 | object: oo 29 | }); 30 | assert.deepStrictEqual(events[2], { 31 | type: 'delete', 32 | path: ['some'], 33 | oldValue: 'else', 34 | value: undefined, 35 | object: oo 36 | }); 37 | }); 38 | 39 | test('test listeners invocation - multiple listeners', () => { 40 | const oo = Observable.from({}); 41 | let eventsA = [], eventsB = [], eventsC = []; 42 | 43 | Observable.observe(oo, changes => { eventsA = eventsA.concat(changes); }); 44 | Observable.observe(oo, changes => { eventsB = eventsB.concat(changes); }); 45 | Observable.observe(oo, changes => { eventsC = eventsC.concat(changes); }); 46 | 47 | oo.some = 'test'; 48 | oo.some = 'else'; 49 | delete oo.some; 50 | 51 | assert.equal(eventsA.length, 3); 52 | assert.equal(eventsB.length, 3); 53 | assert.equal(eventsC.length, 3); 54 | }); 55 | 56 | test('test listeners invocation - multiple listeners and one is throwing', () => { 57 | const oo = Observable.from({}); 58 | let eventsA = [], eventsB = []; 59 | 60 | Observable.observe(oo, () => { 61 | throw new Error('intentional disrupt'); 62 | }); 63 | Observable.observe(oo, changes => { eventsA = eventsA.concat(changes); }); 64 | Observable.observe(oo, changes => { eventsB = eventsB.concat(changes); }); 65 | 66 | oo.some = 'test'; 67 | oo.some = 'else'; 68 | delete oo.some; 69 | 70 | assert.equal(eventsA.length, 3); 71 | assert.equal(eventsB.length, 3); 72 | }); 73 | 74 | test('test listeners invocation - multiple times same listener', () => { 75 | const 76 | oo = Observable.from({}), 77 | listener = changes => { eventsA = eventsA.concat(changes); }; 78 | let eventsA = []; 79 | 80 | Observable.observe(oo, listener); 81 | Observable.observe(oo, listener); 82 | 83 | oo.some = 'test'; 84 | oo.some = 'else'; 85 | delete oo.some; 86 | 87 | assert.equal(eventsA.length, 3); 88 | }); 89 | 90 | test('test listeners invocation - listener is corrupted - null', () => { 91 | assert.throws(() => Observable.observe(Observable.from({}), null), 'observer MUST be a function'); 92 | }); 93 | 94 | test('test listeners invocation - listener is corrupted - NaF', () => { 95 | assert.throws(() => Observable.observe(Observable.from({}), 'some non function'), 'observer MUST be a function'); 96 | }); 97 | -------------------------------------------------------------------------------- /.github/workflows/quality.yml: -------------------------------------------------------------------------------- 1 | name: Quality pipeline 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | quality_pipeline_lint: 10 | runs-on: ubuntu-20.04 11 | timeout-minutes: 5 12 | 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | 17 | - name: Setup NodeJS 18 | uses: actions/setup-node@v4 19 | with: 20 | node-version-file: '.nvmrc' 21 | 22 | - name: Install 23 | run: npm ci 24 | 25 | - name: Lint 26 | run: npm run lint 27 | 28 | quality_pipeline_nodejs: 29 | runs-on: ubuntu-20.04 30 | timeout-minutes: 5 31 | 32 | steps: 33 | - name: Checkout 34 | uses: actions/checkout@v4 35 | 36 | - name: Setup NodeJS 37 | uses: actions/setup-node@v4 38 | with: 39 | node-version-file: '.nvmrc' 40 | 41 | - name: Install 42 | run: npm ci 43 | 44 | - name: Build 45 | run: npm run build 46 | 47 | - name: Test 48 | run: npm run test:nodejs 49 | 50 | - name: Report coverage 51 | uses: codecov/codecov-action@v5 52 | 53 | quality_pipeline_chromium: 54 | runs-on: ubuntu-20.04 55 | timeout-minutes: 5 56 | 57 | steps: 58 | - name: Checkout 59 | uses: actions/checkout@v4 60 | 61 | - name: Setup NodeJS 62 | uses: actions/setup-node@v4 63 | with: 64 | node-version-file: '.nvmrc' 65 | 66 | - name: Install 67 | run: npm ci && npx playwright install --with-deps chromium 68 | 69 | - name: Test 70 | run: npm run test:chromium 71 | 72 | - name: Report coverage 73 | uses: codecov/codecov-action@v5 74 | 75 | - name: Archive logs 76 | uses: actions/upload-artifact@v4 77 | with: 78 | name: chromium-logs 79 | path: reports/logs/chromium-112.0.5615.29.log 80 | 81 | quality_pipeline_firefox: 82 | runs-on: ubuntu-20.04 83 | timeout-minutes: 5 84 | 85 | steps: 86 | - name: Checkout 87 | uses: actions/checkout@v4 88 | 89 | - name: Setup NodeJS 90 | uses: actions/setup-node@v4 91 | with: 92 | node-version-file: '.nvmrc' 93 | 94 | - name: Install 95 | run: npm ci && npx playwright install --with-deps firefox 96 | 97 | - name: Test 98 | run: npm run test:firefox 99 | 100 | - name: Report coverage 101 | uses: codecov/codecov-action@v5 102 | 103 | quality_pipeline_webkit: 104 | runs-on: ubuntu-20.04 105 | timeout-minutes: 5 106 | 107 | steps: 108 | - name: Checkout 109 | uses: actions/checkout@v4 110 | 111 | - name: Setup NodeJS 112 | uses: actions/setup-node@v4 113 | with: 114 | node-version-file: '.nvmrc' 115 | 116 | - name: Install 117 | run: npm ci && npx playwright install --with-deps webkit 118 | 119 | - name: Test 120 | run: npm run test:webkit 121 | 122 | - name: Report coverage 123 | uses: codecov/codecov-action@v5 124 | -------------------------------------------------------------------------------- /src/object-observer.d.ts: -------------------------------------------------------------------------------- 1 | export type ChangeType = 'insert' | 'update' | 'delete' | 'reverse' | 'shuffle'; 2 | 3 | /** 4 | * `Observable` allows to observe any (deep) changes on its underlying object graph 5 | * 6 | * - created by `from` static method, via cloning the target 7 | * - important: the type `T` is not preserved, beside its shape 8 | */ 9 | export abstract class Observable { 10 | 11 | /** 12 | * create Observable from the target 13 | * - target is cloned, remaining unchanged in itself 14 | * - important: the type `T` is NOT preserved, beside its shape 15 | * 16 | * @param target source, to create `Observable` from 17 | * @param options observable options 18 | */ 19 | static from(target: T, options?: ObservableOptions): Observable & T; 20 | 21 | /** 22 | * check input for being `Observable` 23 | * 24 | * @param input any object to be checked as `Observable` 25 | */ 26 | static isObservable(input: unknown): boolean; 27 | 28 | /** 29 | * add observer to handle the observable's changes 30 | * 31 | * @param observable observable to set observer on 32 | * @param observer observer function / logic 33 | * @param options observation options 34 | */ 35 | static observe(observable: Observable, observer: Observer, options?: ObserverOptions): void; 36 | 37 | /** 38 | * remove observer/s from observable 39 | * 40 | * @param observable observable to remove observer/s from 41 | * @param observers 0 to many observers to remove; if none supplied, ALL observers will be removed 42 | */ 43 | static unobserve(observable: Observable, ...observers: Observer[]): void; 44 | } 45 | 46 | export interface ObservableOptions { 47 | async: boolean; 48 | } 49 | 50 | export interface Observer { 51 | (changes: Change[]): void; 52 | } 53 | 54 | export interface ObserverOptions { 55 | path?: string, 56 | pathsOf?: string, 57 | pathsFrom?: string 58 | } 59 | 60 | export interface Change { 61 | type: ChangeType; 62 | path: string[]; 63 | value?: any; 64 | oldValue?: any; 65 | object: object; 66 | } 67 | 68 | /** 69 | * `ObjectObserver` provides observation functionality in a WebAPI-like flavor 70 | * - `observer` created first, with the provided observer function 71 | * - `observer` may then be used to observe different targets 72 | */ 73 | export class ObjectObserver { 74 | 75 | /** 76 | * sets up observer function and options 77 | * @param observer observation logic (function) 78 | * @param options `ObservableOptions` will be applied to any `Observable` down the road 79 | */ 80 | constructor(observer: Observer, options?: ObservableOptions); 81 | 82 | /** 83 | * create `Observable` from the target and starts observation 84 | * - important: the type `T` is NOT preserved, beside its shape 85 | * @param target target to be observed, turned into `Observable` via cloning 86 | * @param options `ObserverOptions` options 87 | */ 88 | observe(target: T, options?: ObserverOptions): Observable & T; 89 | 90 | /** 91 | * un-observes the `Observable`, returning the original undelying plain object 92 | * @param target target to be un-observed 93 | */ 94 | unobserve(target: Observable): void; 95 | 96 | disconnect(): void; 97 | } 98 | -------------------------------------------------------------------------------- /tests/object-observer-api.js: -------------------------------------------------------------------------------- 1 | import { test } from '@gullerya/just-test'; 2 | import { assert } from '@gullerya/just-test/assert'; 3 | import { ObjectObserver, Observable } from '../src/object-observer.js'; 4 | 5 | test('ensure ObjectObserver constructable', () => { 6 | assert.isTrue(typeof ObjectObserver === 'function'); 7 | assert.isTrue(String(ObjectObserver).includes('class')); 8 | }); 9 | 10 | test('observe 1 object', () => { 11 | let calls = 0; 12 | const oo = new ObjectObserver(changes => { 13 | calls += changes.length; 14 | }); 15 | const o = oo.observe({ a: 'a', b: 'b' }); 16 | 17 | o.a = 'b'; 18 | assert.equal(1, calls); 19 | }); 20 | 21 | test('observe returns new Observable', () => { 22 | const oo = new ObjectObserver(() => { }); 23 | const o = {}; 24 | const o1 = oo.observe(o); 25 | assert.notEqual(o, o1); 26 | assert.isTrue(Observable.isObservable(o1)); 27 | }); 28 | 29 | test('observe returns same Observable if supplied with Observable', () => { 30 | const oo = new ObjectObserver(() => { }); 31 | const o1 = Observable.from({}); 32 | const o2 = oo.observe(o1); 33 | assert.equal(o1, o2); 34 | }); 35 | 36 | test('observe 3 objects', () => { 37 | const events = []; 38 | const oo = new ObjectObserver(changes => { 39 | events.push(...changes); 40 | }); 41 | const o1 = oo.observe({ a: 'a' }); 42 | const o2 = oo.observe({ b: 'b' }); 43 | const o3 = oo.observe({ c: 'c' }); 44 | 45 | o1.a = 'A'; 46 | delete o2.b; 47 | o3.d = 'd'; 48 | 49 | assert.equal(3, events.length); 50 | assert.equal('update', events[0].type); 51 | assert.equal(o1, events[0].object); 52 | assert.equal('delete', events[1].type); 53 | assert.equal(o2, events[1].object); 54 | assert.equal('insert', events[2].type); 55 | assert.equal(o3, events[2].object); 56 | }); 57 | 58 | test('observe 3 objects then unobserve 1', () => { 59 | const events = []; 60 | const oo = new ObjectObserver(changes => { 61 | events.push(...changes); 62 | }); 63 | const o1 = oo.observe({ a: 'a' }); 64 | const o2 = oo.observe({ b: 'b' }); 65 | const o3 = oo.observe({ c: 'c' }); 66 | 67 | o1.a = 'A'; 68 | 69 | assert.equal(1, events.length); 70 | assert.equal('update', events[0].type); 71 | assert.equal(o1, events[0].object); 72 | 73 | oo.unobserve(o1); 74 | 75 | o1.a = '123'; 76 | delete o2.b; 77 | o3.d = 'd'; 78 | 79 | assert.equal(3, events.length); 80 | assert.equal('delete', events[1].type); 81 | assert.equal(o2, events[1].object); 82 | assert.equal('insert', events[2].type); 83 | assert.equal(o3, events[2].object); 84 | }); 85 | 86 | test('observe 3 objects > disconnect > observe', () => { 87 | const events = []; 88 | const oo = new ObjectObserver(changes => { 89 | events.push(...changes); 90 | }); 91 | const o1 = oo.observe({ a: 'a' }); 92 | const o2 = oo.observe({ b: 'b' }); 93 | const o3 = oo.observe({ c: 'c' }); 94 | 95 | o1.a = 'A'; 96 | delete o2.b; 97 | o3.d = 'd'; 98 | assert.equal(3, events.length); 99 | 100 | events.splice(0); 101 | oo.disconnect(); 102 | 103 | o1.a = '123'; 104 | delete o2.b; 105 | o3.d = 'd'; 106 | assert.equal(0, events.length); 107 | 108 | oo.observe(o1); 109 | oo.observe(o2); 110 | 111 | o1.a = '1234'; 112 | o2.b = 'something'; 113 | assert.equal(2, events.length); 114 | }); 115 | 116 | // TODO: observe with options -------------------------------------------------------------------------------- /tests/unobserve.js: -------------------------------------------------------------------------------- 1 | import { test } from '@gullerya/just-test'; 2 | import { assert } from '@gullerya/just-test/assert'; 3 | import { Observable } from '../src/object-observer.js'; 4 | 5 | test('test unobserve - single observer - explicit unobserve', () => { 6 | const 7 | oo = Observable.from({ some: 'text' }), 8 | observer = function () { 9 | cntr++; 10 | }; 11 | let cntr = 0; 12 | 13 | Observable.observe(oo, observer); 14 | 15 | oo.some = 'thing'; 16 | assert.equal(cntr, 1); 17 | 18 | cntr = 0; 19 | Observable.unobserve(oo, observer); 20 | oo.some = 'true'; 21 | assert.equal(cntr, 0); 22 | }); 23 | 24 | test('test unobserve - few observers - explicit unobserve', () => { 25 | const 26 | oo = Observable.from({ some: 'text' }), 27 | observerA = function () { 28 | cntrA++; 29 | }, 30 | observerB = function () { 31 | cntrB++; 32 | }; 33 | let cntrA = 0, 34 | cntrB = 0; 35 | 36 | Observable.observe(oo, observerA); 37 | Observable.observe(oo, observerB); 38 | 39 | oo.some = 'thing'; 40 | assert.equal(cntrA, 1); 41 | assert.equal(cntrB, 1); 42 | 43 | cntrA = 0; 44 | cntrB = 0; 45 | Observable.unobserve(oo, observerA); 46 | oo.some = 'true'; 47 | assert.equal(cntrA, 0); 48 | assert.equal(cntrB, 1); 49 | 50 | cntrA = 0; 51 | cntrB = 0; 52 | Observable.unobserve(oo, observerB); 53 | oo.some = 'back'; 54 | assert.equal(cntrA, 0); 55 | assert.equal(cntrB, 0); 56 | }); 57 | 58 | test('test unobserve - unobserve few', () => { 59 | const 60 | oo = Observable.from({ some: 'text' }), 61 | observerA = function () { 62 | cntrA++; 63 | }, 64 | observerB = function () { 65 | cntrB++; 66 | }; 67 | let cntrA = 0, 68 | cntrB = 0; 69 | 70 | Observable.observe(oo, observerA); 71 | Observable.observe(oo, observerB); 72 | 73 | oo.some = 'thing'; 74 | assert.equal(cntrA, 1); 75 | assert.equal(cntrB, 1); 76 | 77 | cntrA = 0; 78 | cntrB = 0; 79 | Observable.unobserve(oo, observerA, observerB); 80 | oo.some = 'true'; 81 | assert.equal(cntrA, 0); 82 | assert.equal(cntrB, 0); 83 | }); 84 | 85 | test('test unobserve - unobserve all', () => { 86 | const 87 | oo = Observable.from({ some: 'text' }), 88 | observerA = function () { 89 | cntrA++; 90 | }, 91 | observerB = function () { 92 | cntrB++; 93 | }; 94 | let cntrA = 0, 95 | cntrB = 0; 96 | 97 | Observable.observe(oo, observerA); 98 | Observable.observe(oo, observerB); 99 | 100 | oo.some = 'thing'; 101 | assert.equal(cntrA, 1); 102 | assert.equal(cntrB, 1); 103 | 104 | cntrA = 0; 105 | cntrB = 0; 106 | Observable.unobserve(oo); 107 | oo.some = 'true'; 108 | assert.equal(cntrA, 0); 109 | assert.equal(cntrB, 0); 110 | }); 111 | 112 | test('test unobserve - observe, unobserve and observe again', () => { 113 | const 114 | oo = Observable.from({ some: 'text' }), 115 | observer = function () { 116 | cntr++; 117 | }; 118 | let cntr = 0; 119 | 120 | Observable.observe(oo, observer); 121 | oo.some = 'thing'; 122 | assert.equal(cntr, 1); 123 | 124 | Observable.unobserve(oo); 125 | oo.some = 'true'; 126 | assert.equal(cntr, 1); 127 | 128 | Observable.observe(oo, observer); 129 | oo.some = 'again'; 130 | assert.equal(cntr, 2); 131 | }); 132 | 133 | test('test unobserve - on observers set case', () => { 134 | const oo = Observable.from({ some: 'text' }); 135 | 136 | Observable.unobserve(oo, () => { }); 137 | }); 138 | -------------------------------------------------------------------------------- /tests/api-changes.js: -------------------------------------------------------------------------------- 1 | import { test } from '@gullerya/just-test'; 2 | import { assert } from '@gullerya/just-test/assert'; 3 | import { Observable } from '../src/object-observer.js'; 4 | 5 | // object 6 | // 7 | test('verify object - root - insert', () => { 8 | let c; 9 | const o = Observable.from({}); 10 | Observable.observe(o, cs => { c = cs[0]; }) 11 | o.some = 'new'; 12 | assert.deepStrictEqual(c, { type: 'insert', path: ['some'], value: 'new', oldValue: undefined, object: o }); 13 | }); 14 | 15 | test('verify object - deep - insert', () => { 16 | let c; 17 | const o = Observable.from({ a: {} }); 18 | Observable.observe(o, cs => { c = cs[0]; }) 19 | o.a.some = 'new'; 20 | assert.deepStrictEqual(c, { type: 'insert', path: ['a', 'some'], value: 'new', oldValue: undefined, object: o.a }); 21 | }); 22 | 23 | test('verify object - root - update', () => { 24 | let c; 25 | const o = Observable.from({ p: 'old' }); 26 | Observable.observe(o, cs => { c = cs[0]; }) 27 | o.p = 'new'; 28 | assert.deepStrictEqual(c, { type: 'update', path: ['p'], value: 'new', oldValue: 'old', object: o }); 29 | }); 30 | 31 | test('verify object - deep - update', () => { 32 | let c; 33 | const o = Observable.from({ a: { p: 'old' } }); 34 | Observable.observe(o, cs => { c = cs[0]; }) 35 | o.a.p = 'new'; 36 | assert.deepStrictEqual(c, { type: 'update', path: ['a', 'p'], value: 'new', oldValue: 'old', object: o.a }); 37 | }); 38 | 39 | test('verify object - root - delete', () => { 40 | let c; 41 | const o = Observable.from({ p: 'old' }); 42 | Observable.observe(o, cs => { c = cs[0]; }) 43 | delete o.p; 44 | assert.deepStrictEqual(c, { type: 'delete', path: ['p'], value: undefined, oldValue: 'old', object: o }); 45 | }); 46 | 47 | test('verify object - deep - delete', () => { 48 | let c; 49 | const o = Observable.from({ a: { p: 'old' } }); 50 | Observable.observe(o, cs => { c = cs[0]; }) 51 | delete o.a.p; 52 | assert.deepStrictEqual(c, { type: 'delete', path: ['a', 'p'], value: undefined, oldValue: 'old', object: o.a }); 53 | }); 54 | 55 | // array 56 | // 57 | test('verify array - root - insert', () => { 58 | let c; 59 | const o = Observable.from([]); 60 | Observable.observe(o, cs => { c = cs[0]; }) 61 | o.push('new'); 62 | assert.deepStrictEqual(c, { type: 'insert', path: [0], value: 'new', oldValue: undefined, object: o }); 63 | }); 64 | 65 | test('verify array - deep - insert', () => { 66 | let c; 67 | const o = Observable.from([[]]); 68 | Observable.observe(o, cs => { c = cs[0]; }) 69 | o[0].push('new'); 70 | assert.deepStrictEqual(c, { type: 'insert', path: [0, 0], value: 'new', oldValue: undefined, object: o[0] }); 71 | }); 72 | 73 | test('verify array - root - update', () => { 74 | let c; 75 | const o = Observable.from(['old']); 76 | Observable.observe(o, cs => { c = cs[0]; }) 77 | o[0] = 'new'; 78 | assert.deepStrictEqual(c, { type: 'update', path: ['0'], value: 'new', oldValue: 'old', object: o }); 79 | }); 80 | 81 | test('verify array - deep - update', () => { 82 | let c; 83 | const o = Observable.from([['old']]); 84 | Observable.observe(o, cs => { c = cs[0]; }) 85 | o[0][0] = 'new'; 86 | assert.deepStrictEqual(c, { type: 'update', path: [0, '0'], value: 'new', oldValue: 'old', object: o[0] }); 87 | }); 88 | 89 | test('verify array - root - delete', () => { 90 | let c; 91 | const o = Observable.from(['old']); 92 | Observable.observe(o, cs => { c = cs[0]; }) 93 | o.pop(); 94 | assert.deepStrictEqual(c, { type: 'delete', path: [0], value: undefined, oldValue: 'old', object: o }); 95 | }); 96 | 97 | test('verify array - deep - delete', () => { 98 | let c; 99 | const o = Observable.from([['old']]); 100 | Observable.observe(o, cs => { c = cs[0]; }) 101 | o[0].pop(); 102 | assert.deepStrictEqual(c, { type: 'delete', path: [0, 0], value: undefined, oldValue: 'old', object: o[0] }); 103 | }); 104 | -------------------------------------------------------------------------------- /tests/object-observer-arrays-copy-within.js: -------------------------------------------------------------------------------- 1 | import { test } from '@gullerya/just-test'; 2 | import { assert } from '@gullerya/just-test/assert'; 3 | import { Observable } from '../src/object-observer.js'; 4 | 5 | test('array copyWithin - primitives', () => { 6 | const 7 | pa = Observable.from([1, 2, 3, 4, 5, 6]), 8 | events = []; 9 | let callbacks = 0; 10 | 11 | Observable.observe(pa, eventsList => { 12 | [].push.apply(events, eventsList); 13 | callbacks++; 14 | }); 15 | 16 | let copied = pa.copyWithin(2, 0, 3); 17 | assert.equal(pa, copied); 18 | assert.strictEqual(events.length, 3); 19 | assert.strictEqual(callbacks, 1); 20 | assert.deepStrictEqual(events[0], { type: 'update', path: [2], value: 1, oldValue: 3, object: pa }); 21 | assert.deepStrictEqual(events[1], { type: 'update', path: [3], value: 2, oldValue: 4, object: pa }); 22 | assert.deepStrictEqual(events[2], { type: 'update', path: [4], value: 3, oldValue: 5, object: pa }); 23 | events.splice(0); 24 | callbacks = 0; 25 | 26 | // pa = [1,2,1,2,3,6] 27 | copied = pa.copyWithin(-3); 28 | assert.equal(pa, copied); 29 | assert.strictEqual(events.length, 3); 30 | assert.strictEqual(callbacks, 1); 31 | assert.deepStrictEqual(events[0], { type: 'update', path: [3], value: 1, oldValue: 2, object: pa }); 32 | assert.deepStrictEqual(events[1], { type: 'update', path: [4], value: 2, oldValue: 3, object: pa }); 33 | assert.deepStrictEqual(events[2], { type: 'update', path: [5], value: 1, oldValue: 6, object: pa }); 34 | events.splice(0); 35 | callbacks = 0; 36 | 37 | // pa = [1,2,1,1,2,1] 38 | copied = pa.copyWithin(1, -3, 9); 39 | assert.equal(pa, copied); 40 | assert.strictEqual(events.length, 2); 41 | assert.strictEqual(callbacks, 1); 42 | assert.deepStrictEqual(events[0], { type: 'update', path: [1], value: 1, oldValue: 2, object: pa }); 43 | assert.deepStrictEqual(events[1], { type: 'update', path: [2], value: 2, oldValue: 1, object: pa }); 44 | // update at index 4 should not be evented, since 1 === 1 45 | events.splice(0); 46 | callbacks = 0; 47 | }); 48 | 49 | test('array copyWithin - objects', () => { 50 | const 51 | pa = Observable.from([{ text: 'a' }, { text: 'b' }, { text: 'c' }, { text: 'd' }]), 52 | events = []; 53 | 54 | Observable.observe(pa, eventsList => { 55 | [].push.apply(events, eventsList); 56 | }); 57 | 58 | const detached = pa[1]; 59 | const copied = pa.copyWithin(1, 2, 3); 60 | assert.equal(pa, copied); 61 | assert.strictEqual(events.length, 1); 62 | assert.deepStrictEqual(events[0], { type: 'update', path: [1], value: { text: 'c' }, oldValue: { text: 'b' }, object: pa }); 63 | events.splice(0); 64 | 65 | pa[1].text = 'B'; 66 | pa[2].text = 'D'; 67 | assert.strictEqual(events.length, 2); 68 | assert.deepStrictEqual(events[0], { type: 'update', path: [1, 'text'], value: 'B', oldValue: 'c', object: pa[1] }); 69 | assert.deepStrictEqual(events[1], { type: 'update', path: [2, 'text'], value: 'D', oldValue: 'c', object: pa[2] }); 70 | events.splice(0); 71 | 72 | Observable.observe(detached, eventsList => { 73 | [].push.apply(events, eventsList); 74 | }); 75 | detached.text = '1'; 76 | assert.equal(events.length, 1); 77 | assert.deepStrictEqual(events[0], { type: 'update', path: ['text'], value: '1', oldValue: 'b', object: detached }); 78 | }); 79 | 80 | test('array copyWithin - arrays', () => { 81 | const 82 | pa = Observable.from([{ text: 'a' }, { text: 'b' }, { text: 'c' }, [{ text: 'd' }]]), 83 | events = []; 84 | 85 | Observable.observe(pa, eventsList => { 86 | [].push.apply(events, eventsList); 87 | }); 88 | 89 | const copied = pa.copyWithin(1, 3, 4); 90 | assert.equal(pa, copied); 91 | assert.equal(events.length, 1); 92 | assert.deepStrictEqual(events[0], { type: 'update', path: [1], value: [{ text: 'd' }], oldValue: { text: 'b' }, object: pa }); 93 | events.splice(0); 94 | 95 | pa[1][0].text = 'B'; 96 | pa[3][0].text = 'D'; 97 | assert.strictEqual(events.length, 2); 98 | assert.deepStrictEqual(events[0], { type: 'update', path: [1, 0, 'text'], value: 'B', oldValue: 'd', object: pa[1][0] }); 99 | assert.deepStrictEqual(events[1], { type: 'update', path: [3, 0, 'text'], value: 'D', oldValue: 'd', object: pa[3][0] }); 100 | events.splice(0); 101 | }); -------------------------------------------------------------------------------- /tests/workers/perf-sync-test-b.js: -------------------------------------------------------------------------------- 1 | import { Observable } from '../../src/object-observer.js'; 2 | 3 | export default setup => { 4 | const { 5 | TOLERANCE_MULTIPLIER, 6 | ARRAY_ITERATIONS, 7 | ARRAY_PUSH_TRSHLD, 8 | ARRAY_MUTATION_TRSHLD, 9 | ARRAY_POP_TRSHLD 10 | } = setup; 11 | 12 | let ttl, avg; 13 | const 14 | o = { 15 | name: 'Anna Guller', 16 | accountCreated: new Date(), 17 | age: 20, 18 | address: { 19 | city: 'Dreamland', 20 | street: { 21 | name: 'Hope', 22 | apt: 123 23 | } 24 | }, 25 | orders: [] 26 | }, 27 | orders = [ 28 | { id: 1, description: 'some description', sum: 1234, date: new Date() }, 29 | { id: 2, description: 'some description', sum: 1234, date: new Date() }, 30 | { id: 3, description: 'some description', sum: 1234, date: new Date() } 31 | ]; 32 | let changesCountA, 33 | changesCountB, 34 | started, 35 | ended; 36 | 37 | // creation of Observable 38 | const po = Observable.from({ users: [] }); 39 | 40 | // add listeners/callbacks 41 | Observable.observe(po, changes => changesCountA += changes.length); 42 | Observable.observe(po, changes => changesCountB += changes.length); 43 | 44 | // push objects 45 | changesCountA = 0; 46 | changesCountB = 0; 47 | console.info(`performing ${ARRAY_ITERATIONS} objects pushes...`); 48 | started = performance.now(); 49 | for (let i = 0; i < ARRAY_ITERATIONS; i++) { 50 | po.users.push(o); 51 | } 52 | ended = performance.now(); 53 | ttl = ended - started; 54 | avg = ttl / ARRAY_ITERATIONS; 55 | if (po.users.length !== ARRAY_ITERATIONS) throw new Error(`expected ${ARRAY_ITERATIONS}, got ${po.users.length}`); 56 | if (changesCountA !== ARRAY_ITERATIONS) throw new Error(`expected ${ARRAY_ITERATIONS}, got ${changesCountA}`); 57 | if (changesCountA !== ARRAY_ITERATIONS) throw new Error(`expected ${ARRAY_ITERATIONS}, got ${changesCountB}`); 58 | console.info(`... push of ${ARRAY_ITERATIONS} objects done: total - ${ttl.toFixed(2)}ms, average - ${avg.toFixed(4)}ms`); 59 | if (avg > ARRAY_PUSH_TRSHLD * TOLERANCE_MULTIPLIER) { 60 | throw new Error(`create perf assert failed, expected at most ${ARRAY_PUSH_TRSHLD * TOLERANCE_MULTIPLIER}, got ${avg}`); 61 | } else { 62 | console.info(`PUSH: expected - ${ARRAY_PUSH_TRSHLD}, measured - ${avg.toFixed(4)}: PASSED`); 63 | } 64 | 65 | // add orders array to each one of them 66 | changesCountA = 0; 67 | changesCountB = 0; 68 | console.info(`performing ${ARRAY_ITERATIONS} additions of arrays onto the objects...`); 69 | started = performance.now(); 70 | for (let i = 0; i < ARRAY_ITERATIONS; i++) { 71 | po.users[i].orders = orders; 72 | } 73 | ended = performance.now(); 74 | ttl = ended - started; 75 | avg = ttl / ARRAY_ITERATIONS; 76 | if (changesCountA !== ARRAY_ITERATIONS) throw new Error(`expected ${ARRAY_ITERATIONS}, got ${changesCountA}`); 77 | if (changesCountA !== ARRAY_ITERATIONS) throw new Error(`expected ${ARRAY_ITERATIONS}, got ${changesCountB}`); 78 | console.info(`... add of ${ARRAY_ITERATIONS} array items done: total - ${ttl.toFixed(2)}ms, average - ${avg.toFixed(4)}ms`); 79 | if (avg > ARRAY_MUTATION_TRSHLD * TOLERANCE_MULTIPLIER) { 80 | throw new Error(`create perf assert failed, expected at most ${ARRAY_MUTATION_TRSHLD * TOLERANCE_MULTIPLIER}, got ${avg}`); 81 | } else { 82 | console.info(`ARRAY UPDATE: expected - ${ARRAY_MUTATION_TRSHLD}, measured - ${avg.toFixed(4)}: PASSED`); 83 | } 84 | 85 | // pop objects 86 | changesCountA = 0; 87 | changesCountB = 0; 88 | console.info(`performing ${ARRAY_ITERATIONS} object pops...`); 89 | started = performance.now(); 90 | for (let i = 0; i < ARRAY_ITERATIONS; i++) { 91 | po.users.pop(); 92 | } 93 | ended = performance.now(); 94 | ttl = ended - started; 95 | avg = ttl / ARRAY_ITERATIONS; 96 | if (po.users.length !== 0) throw new Error(`expected ${0}, got ${po.users.length}`); 97 | if (changesCountA !== ARRAY_ITERATIONS) throw new Error(`expected ${ARRAY_ITERATIONS}, got ${changesCountA}`); 98 | if (changesCountA !== ARRAY_ITERATIONS) throw new Error(`expected ${ARRAY_ITERATIONS}, got ${changesCountB}`); 99 | console.info(`... pop of ${ARRAY_ITERATIONS} array items done: total - ${ttl.toFixed(2)}ms, average - ${avg.toFixed(4)}ms`); 100 | if (avg > ARRAY_POP_TRSHLD * TOLERANCE_MULTIPLIER) { 101 | throw new Error(`create perf assert failed, expected at most ${ARRAY_POP_TRSHLD * TOLERANCE_MULTIPLIER}, got ${avg}`); 102 | } else { 103 | console.info(`POP: expected - ${ARRAY_POP_TRSHLD}, measured - ${avg.toFixed(4)}: PASSED`); 104 | } 105 | }; -------------------------------------------------------------------------------- /tests/workers/perf-async-test-b.js: -------------------------------------------------------------------------------- 1 | import { Observable } from '../../src/object-observer.js'; 2 | 3 | export default async setup => { 4 | const { 5 | TOLERANCE_MULTIPLIER, 6 | ARRAY_ITERATIONS, 7 | ARRAY_PUSH_TRSHLD, 8 | ARRAY_MUTATION_TRSHLD, 9 | ARRAY_POP_TRSHLD 10 | } = setup; 11 | 12 | let ttl, avg; 13 | const 14 | o = { 15 | name: 'Anna Guller', 16 | accountCreated: new Date(), 17 | age: 20, 18 | address: { 19 | city: 'Dreamland', 20 | street: { 21 | name: 'Hope', 22 | apt: 123 23 | } 24 | }, 25 | orders: [] 26 | }, 27 | orders = [ 28 | { id: 1, description: 'some description', sum: 1234, date: new Date() }, 29 | { id: 2, description: 'some description', sum: 1234, date: new Date() }, 30 | { id: 3, description: 'some description', sum: 1234, date: new Date() } 31 | ]; 32 | let changesCountA, 33 | changesCountB, 34 | started, 35 | ended; 36 | 37 | // creation of Observable 38 | const po = Observable.from({ users: [] }, { async: true }); 39 | 40 | // add listeners/callbacks 41 | Observable.observe(po, changes => changesCountA += changes.length); 42 | Observable.observe(po, changes => changesCountB += changes.length); 43 | 44 | // push objects 45 | changesCountA = 0; 46 | changesCountB = 0; 47 | console.info(`[async] performing ${ARRAY_ITERATIONS} objects pushes...`); 48 | started = performance.now(); 49 | for (let i = 0; i < ARRAY_ITERATIONS; i++) { 50 | po.users.push(o); 51 | } 52 | await new Promise(r => setTimeout(r, 0)); 53 | ended = performance.now(); 54 | ttl = ended - started; 55 | avg = ttl / ARRAY_ITERATIONS; 56 | if (po.users.length !== ARRAY_ITERATIONS) throw new Error(`expected ${ARRAY_ITERATIONS}, got ${po.users.length}`); 57 | if (changesCountA !== ARRAY_ITERATIONS) throw new Error(`expected ${ARRAY_ITERATIONS}, got ${changesCountA}`); 58 | if (changesCountA !== ARRAY_ITERATIONS) throw new Error(`expected ${ARRAY_ITERATIONS}, got ${changesCountB}`); 59 | console.info(`... [async] push of ${ARRAY_ITERATIONS} objects done: total - ${ttl.toFixed(2)}ms, average - ${avg.toFixed(4)}ms`); 60 | if (avg > ARRAY_PUSH_TRSHLD * TOLERANCE_MULTIPLIER) { 61 | throw new Error(`create perf assert failed, expected at most ${ARRAY_PUSH_TRSHLD * TOLERANCE_MULTIPLIER}, got ${avg}`); 62 | } else { 63 | console.info(`PUSH [async]: expected - ${ARRAY_PUSH_TRSHLD}, measured - ${avg.toFixed(4)}: PASSED`); 64 | } 65 | 66 | // add orders array to each one of them 67 | changesCountA = 0; 68 | changesCountB = 0; 69 | console.info(`[async] performing ${ARRAY_ITERATIONS} additions of arrays onto the objects...`); 70 | started = performance.now(); 71 | for (let i = 0; i < ARRAY_ITERATIONS; i++) { 72 | po.users[i].orders = orders; 73 | } 74 | await new Promise(r => setTimeout(r, 0)); 75 | ended = performance.now(); 76 | ttl = ended - started; 77 | avg = ttl / ARRAY_ITERATIONS; 78 | if (changesCountA !== ARRAY_ITERATIONS) throw new Error(`expected ${ARRAY_ITERATIONS}, got ${changesCountA}`); 79 | if (changesCountA !== ARRAY_ITERATIONS) throw new Error(`expected ${ARRAY_ITERATIONS}, got ${changesCountB}`); 80 | console.info(`... [async] add of ${ARRAY_ITERATIONS} array items done: total - ${ttl.toFixed(2)}ms, average - ${avg.toFixed(4)}ms`); 81 | if (avg > ARRAY_MUTATION_TRSHLD * TOLERANCE_MULTIPLIER) { 82 | throw new Error(`create perf assert failed, expected at most ${ARRAY_MUTATION_TRSHLD * TOLERANCE_MULTIPLIER}, got ${avg}`); 83 | } else { 84 | console.info(`ARRAY UPDATE [async]: expected - ${ARRAY_MUTATION_TRSHLD}, measured - ${avg.toFixed(4)}: PASSED`); 85 | } 86 | 87 | // pop objects 88 | changesCountA = 0; 89 | changesCountB = 0; 90 | console.info(`[async] performing ${ARRAY_ITERATIONS} object pops...`); 91 | started = performance.now(); 92 | for (let i = 0; i < ARRAY_ITERATIONS; i++) { 93 | po.users.pop(); 94 | } 95 | await new Promise(r => setTimeout(r, 0)); 96 | ended = performance.now(); 97 | ttl = ended - started; 98 | avg = ttl / ARRAY_ITERATIONS; 99 | if (po.users.length !== 0) throw new Error(`expected ${0}, got ${po.users.length}`); 100 | if (changesCountA !== ARRAY_ITERATIONS) throw new Error(`expected ${ARRAY_ITERATIONS}, got ${changesCountA}`); 101 | if (changesCountA !== ARRAY_ITERATIONS) throw new Error(`expected ${ARRAY_ITERATIONS}, got ${changesCountB}`); 102 | console.info(`... [async] pop of ${ARRAY_ITERATIONS} array items done: total - ${ttl.toFixed(2)}ms, average - ${avg.toFixed(4)}ms`); 103 | if (avg > ARRAY_POP_TRSHLD * TOLERANCE_MULTIPLIER) { 104 | throw new Error(`create perf assert failed, expected at most ${ARRAY_POP_TRSHLD * TOLERANCE_MULTIPLIER}, got ${avg}`); 105 | } else { 106 | console.info(`POP [async]: expected - ${ARRAY_POP_TRSHLD}, measured - ${avg.toFixed(4)}: PASSED`); 107 | } 108 | }; -------------------------------------------------------------------------------- /docs/cdn.md: -------------------------------------------------------------------------------- 1 | # CDN provisioning 2 | 3 | CDN provisioning is a general convenience feature, which also provides: 4 | - security: 5 | - __HTTPS__ only 6 | - __intergrity__ checksum provided, see below 7 | - performance 8 | - highly __available__ (with many geo spread edges) 9 | - agressive __caching__ setup 10 | 11 | ## Usage 12 | 13 | Import `object-observer` directly from CDN: 14 | ```js 15 | import { Observable } from 'https://libs.gullerya.com/object-observer/x.y.z/object-observer.min.js'; 16 | ``` 17 | 18 | > Note: regular and minified resouces are available. 19 | 20 | > Note: replace the `x.y.z` with the desired version, one of the listed in the [changelog](changelog.md). 21 | 22 | ## Integrity (SRI) 23 | 24 | Security feature `integrity` was introduced specifically to fortify a consumption of a CDN delivered modules. 25 | `object-observer` adheres to this effort and provides integrity checksums per release (starting from version `4.2.4`). 26 | 27 | ### Usage example 28 | 29 | To begin with, detailed description on SRI (Subresource Integrity) [found here](https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity). 30 | 31 | Since `object-observer` provides ES6 module syntax, the approach described in the documentation above and elsewhere is not applicable. 32 | In this case, and until a better way available (like [this proposal](https://github.com/tc39/proposal-import-assertions/issues/113) of myself), one is required to use `` in __addition__ to the regular `import` in order to enforce integrity validation. 33 | 34 | Thus, please add the below HTML piece in your HTML, when willing to enforce the module's integrity: 35 | ```html 36 | 39 | ``` 40 | 41 | > Note: version (to be put instead of `x.y.z`) and resource kind (regular/minified) should be the same as you use in the application. Accordingly, replace the `hash` with the relevant value as per version and resource, see below. 42 | 43 | > Note: `modulepreload` in general and `integrity` attribute with it in particular are still having a limited support. 44 | 45 | ### Integrity checksums 46 | 47 | Checksums provided per version for both, regular and minified resources. 48 | 49 | From version `4.6.6` SRI hashes provided via Git and NPM, by `sri.json` file: 50 | - Git: check out version tag, then find `sri.json` file in the repo root 51 | - GitHub: visit the file via GitHub UI at version tag, eg [this link](https://github.com/gullerya/object-observer/blob/v4.6.6/sri.json) (pay attention to the version tag in the URL) 52 | - NPM: do install `object-observer` via `npm install...`, then find `sri.json` file in your `node_modules/object-observer` folder 53 | 54 | Below are SRI hashes of the pre-`4.6.6` version: 55 | 56 | | Version | Resource | Integrity checksum (hash) | 57 | |---------|----------|---------------------------| 58 | | 59 | | 4.6.0 | `object-observer.js` | `sha512-2SzwdAYs8e6Kh9qst3WsrQBHoALF0fqeHzFrbMwjjfPYvUJkpn0c0jBFs/rolSAtYCWK22h+Z3Ht6o5Wy80CyA==` | 60 | | 4.6.0 | `object-observer.min.js` | `sha512-nxyrR/lamznRJA5CgXiTGdyyaXbfUaKa9bLYTcqLHlMG8rznT4j7VBewWGTsrjD5xFQxtn7VnlRhT/MF0hO3fA==` | 61 | | 4.5.0 | `object-observer.js` | `sha512-PRxMMuoda0rmLHMFCguy2CSySHoWyCZtFIy+N7gzBHAOB9QYs0VGx8PbaHUhpyo0VwHDY/2L02bDZzmfAq3aIQ==` | 62 | | 4.5.0 | `object-observer.min.js` | `sha512-Xn4niKWPRT5W502IEgfafP4W+agpLcE6Y/arwL2/kP5FQq1rFP5B6WRZiLPlT++qxGXHkKejduWo6L7SAVh0Sg==` | 63 | | 4.4.0 | `object-observer.js` | `sha512-4l0Q/VlM/3dyYEiH6zp4qQ7oFoe6lcyKFDTU+wJ04LwK9o9hzvBYfmHzFlt4kicfGe4U8u+D+AD3onTQuQBoaw==` | 64 | | 4.4.0 | `object-observer.min.js` | `sha512-zlqhnAOtENZ58r5GzmpbvYQMr9JrII7YrxJ9SEWQXNIZUhL/rZDTm3g0uH1895kbPKv/zIK59XcfrmAWtR/QDA==` | 65 | | 4.3.2 | `object-observer.js` | `sha512-KIVmA1D/MQMPfJ2DunNeugVrTsOjt/q9BU2+C2E4PEMT+Om5kRE8nl/at+zBKbO7yUih/T9VmiQw50mROPfI/A==` | 66 | | 4.3.2 | `object-observer.min.js` | `sha512-lpc5mmJKkVVMt5Cus2qHKN+9WppzIEqyBuT1ROmI2w+dC+RRwi0jB9p0El55Yoh2m5cmDOcXbv3YMyWQd22oZA==` | 67 | | 4.3.1 | `object-observer.js` | `sha512-KIVmA1D/MQMPfJ2DunNeugVrTsOjt/q9BU2+C2E4PEMT+Om5kRE8nl/at+zBKbO7yUih/T9VmiQw50mROPfI/A==` | 68 | | 4.3.1 | `object-observer.min.js` | `sha512-lpc5mmJKkVVMt5Cus2qHKN+9WppzIEqyBuT1ROmI2w+dC+RRwi0jB9p0El55Yoh2m5cmDOcXbv3YMyWQd22oZA==` | 69 | | 4.3.0 | `object-observer.js` | `sha512-KIVmA1D/MQMPfJ2DunNeugVrTsOjt/q9BU2+C2E4PEMT+Om5kRE8nl/at+zBKbO7yUih/T9VmiQw50mROPfI/A==` | 70 | | 4.3.0 | `object-observer.min.js` | `sha512-lpc5mmJKkVVMt5Cus2qHKN+9WppzIEqyBuT1ROmI2w+dC+RRwi0jB9p0El55Yoh2m5cmDOcXbv3YMyWQd22oZA==` | 71 | | 4.2.4 | `object-observer.js` | `sha512-hS94aprLMMSBEKeIeXwdsSNjNjsaxaUjdUH029d5fga93buCNxXMcgusb5ELGUhbzi2qkjfQT8s/6m2PnwvCsQ==` | 72 | | 4.2.4 | `object-observer.min.js` | `sha512-o98LgLvzBtc6j+XkCtt0K3JS9FxYwkDdEWduD1yX8gqRtte1Eg5E8iTfoKzLC+fcB2fYrmzrQM3G2mLm8Z1nOQ==` | 73 | -------------------------------------------------------------------------------- /tests/workers/perf-sync-test-a.js: -------------------------------------------------------------------------------- 1 | import { Observable } from '../../src/object-observer.js'; 2 | 3 | export default setup => { 4 | const { 5 | TOLERANCE_MULTIPLIER, 6 | CREATE_ITERATIONS, 7 | MUTATE_ITERATIONS, 8 | OBJECT_CREATION_TRSHLD, 9 | PRIMITIVE_DEEP_MUTATION_TRSHLD, 10 | PRIMITIVE_DEEP_ADDITION_TRSHLD, 11 | PRIMITIVE_DEEP_DELETION_TRSHLD 12 | } = setup; 13 | 14 | let ttl, avg; 15 | const 16 | o = { 17 | name: 'Anna Guller', 18 | accountCreated: new Date(), 19 | age: 20, 20 | address: { 21 | city: 'Dreamland', 22 | street: { 23 | name: 'Hope', 24 | apt: 123 25 | } 26 | }, 27 | orders: [] 28 | }; 29 | let po, 30 | changesCountA, 31 | changesCountB, 32 | started, 33 | ended; 34 | 35 | // creation of Observable 36 | console.info(`creating ${CREATE_ITERATIONS} observables from object...`); 37 | started = performance.now(); 38 | for (let i = 0; i < CREATE_ITERATIONS; i++) { 39 | po = Observable.from(o); 40 | } 41 | ended = performance.now(); 42 | ttl = ended - started; 43 | avg = ttl / CREATE_ITERATIONS; 44 | console.info(`... create of ${CREATE_ITERATIONS} observables done: total - ${ttl.toFixed(2)}ms, average - ${avg.toFixed(4)}ms`); 45 | if (avg > OBJECT_CREATION_TRSHLD * TOLERANCE_MULTIPLIER) { 46 | throw new Error(`create perf assert failed, expected at most ${OBJECT_CREATION_TRSHLD * TOLERANCE_MULTIPLIER}, got ${avg}`); 47 | } else { 48 | console.info(`CREATE: expected - ${OBJECT_CREATION_TRSHLD}, measured - ${avg.toFixed(4)}: PASSED`); 49 | } 50 | 51 | // add listeners/callbacks 52 | Observable.observe(po, changes => changesCountA += changes.length); 53 | Observable.observe(po, changes => changesCountB += changes.length); 54 | 55 | // mutation of existing property 56 | changesCountA = 0; 57 | changesCountB = 0; 58 | console.info(`performing ${MUTATE_ITERATIONS} deep (x3) primitive mutations...`); 59 | started = performance.now(); 60 | for (let i = 0; i < MUTATE_ITERATIONS; i++) { 61 | po.address.street.apt = i; 62 | } 63 | ended = performance.now(); 64 | ttl = ended - started; 65 | avg = ttl / MUTATE_ITERATIONS; 66 | if (changesCountA !== MUTATE_ITERATIONS) throw new Error(`expected ${MUTATE_ITERATIONS}, got ${changesCountA}`); 67 | if (changesCountA !== MUTATE_ITERATIONS) throw new Error(`expected ${MUTATE_ITERATIONS}, got ${changesCountB}`); 68 | console.info(`... mutate of ${MUTATE_ITERATIONS} X3 deep done: total - ${ttl.toFixed(2)}ms, average - ${avg.toFixed(4)}ms`); 69 | if (avg > PRIMITIVE_DEEP_MUTATION_TRSHLD * TOLERANCE_MULTIPLIER) { 70 | throw new Error(`mutate perf assert failed, expected at most ${PRIMITIVE_DEEP_MUTATION_TRSHLD * TOLERANCE_MULTIPLIER}, got ${avg}`); 71 | } else { 72 | console.info(`UPDATE: expected - ${PRIMITIVE_DEEP_MUTATION_TRSHLD}, measured - ${avg.toFixed(4)}: PASSED`); 73 | } 74 | 75 | // adding new property 76 | changesCountA = 0; 77 | changesCountB = 0; 78 | console.info(`performing ${MUTATE_ITERATIONS} deep (x3) primitive additions...`); 79 | started = performance.now(); 80 | for (let i = 0; i < MUTATE_ITERATIONS; i++) { 81 | po.address.street[i] = i; 82 | } 83 | ended = performance.now(); 84 | ttl = ended - started; 85 | avg = ttl / MUTATE_ITERATIONS; 86 | if (changesCountA !== MUTATE_ITERATIONS) throw new Error(`expected ${MUTATE_ITERATIONS}, got ${changesCountA}`); 87 | if (changesCountA !== MUTATE_ITERATIONS) throw new Error(`expected ${MUTATE_ITERATIONS}, got ${changesCountB}`); 88 | console.info(`... add of ${MUTATE_ITERATIONS} X3 deep done: total - ${ttl.toFixed(2)}ms, average - ${avg.toFixed(4)}ms`); 89 | if (avg > PRIMITIVE_DEEP_ADDITION_TRSHLD * TOLERANCE_MULTIPLIER) { 90 | throw new Error(`add perf assert failed, expected at most ${PRIMITIVE_DEEP_ADDITION_TRSHLD * TOLERANCE_MULTIPLIER}, got ${avg}`); 91 | } else { 92 | console.info(`INSERT: expected - ${PRIMITIVE_DEEP_ADDITION_TRSHLD}, measured - ${avg.toFixed(4)}: PASSED`); 93 | } 94 | 95 | // removing new property 96 | changesCountA = 0; 97 | changesCountB = 0; 98 | console.info(`performing ${MUTATE_ITERATIONS} deep (x3) primitive deletions...`); 99 | started = performance.now(); 100 | for (let i = 0; i < MUTATE_ITERATIONS; i++) { 101 | delete po.address.street[i]; 102 | } 103 | ended = performance.now(); 104 | ttl = ended - started; 105 | avg = ttl / MUTATE_ITERATIONS; 106 | if (changesCountA !== MUTATE_ITERATIONS) throw new Error(`expected ${MUTATE_ITERATIONS}, got ${changesCountA}`); 107 | if (changesCountA !== MUTATE_ITERATIONS) throw new Error(`expected ${MUTATE_ITERATIONS}, got ${changesCountB}`); 108 | console.info(`... delete of ${MUTATE_ITERATIONS} X3 deep done: total - ${ttl.toFixed(2)}ms, average - ${avg.toFixed(4)}ms`); 109 | if (avg > PRIMITIVE_DEEP_DELETION_TRSHLD * TOLERANCE_MULTIPLIER) { 110 | throw new Error(`delete perf assert failed, expected at most ${PRIMITIVE_DEEP_DELETION_TRSHLD * TOLERANCE_MULTIPLIER}, got ${avg}`); 111 | } else { 112 | console.info(`DELETE: expected - ${PRIMITIVE_DEEP_DELETION_TRSHLD}, measured - ${avg.toFixed(4)}: PASSED`); 113 | } 114 | }; -------------------------------------------------------------------------------- /docs/observable.md: -------------------------------------------------------------------------------- 1 | # `Observable` API 2 | 3 | `Observable` API provides the whole life cycle of object observation functionality. 4 | 5 | Additionally, this API defines the `Change` object, list of which being a parameter of an observer callback function. 6 | 7 | > `object-observer` provides `Observable` top level object as a named import: 8 | ``` 9 | import { Observable } from 'object-observer.js' 10 | ``` 11 | 12 | ## Static methods 13 | 14 | ### `Observable.`__`from(input[, options])`__ 15 | 16 | - input is a _non-null object_; returns input's __clone__, decorated with an __`Observable`__ interface 17 | - clone is deep 18 | - nested objects are turned into `Observable` instances too 19 | - cloning performed only on __own enumerable__ properties, leaving a possibility to 'hide' some data from observation 20 | 21 | - options is an _object_, optional; may include any of these: 22 | - `async`: _boolean_, defaults to `false`, controls the sync/async fashion of changes delivery; [details here](sync-async.md) 23 | > This flag will affect all observers of this `Observable` 24 | 25 | ```javascript 26 | let person = { 27 | name: 'Aya', 28 | age: '1', 29 | address: { 30 | city: 'city', 31 | street: 'street' 32 | } 33 | }, 34 | observablePerson; 35 | 36 | observablePerson = Observable.from(person); 37 | ``` 38 | 39 | ### `Observable.`__`isObservable(input)`__ 40 | 41 | - input is a _non-null object_; returns `true` if it stands for implementation of `Observable` API as it is defined here 42 | 43 | ```javascript 44 | Observable.isObservable({}); // false 45 | Observable.isObservable(observablePerson); // true 46 | Observable.isObservable(observablePerson.address); // true 47 | ``` 48 | 49 | ### `Observable.`__`observe(observable, callback[, options])`__ 50 | - `observable` MUST be an instance of `Observable` (see `from`) 51 | - callback is a _function_, which will be added to the list of observers subscribed for a changes of this observable; changes delivered always as a never-null-nor-empty array of [__`Change`__](#change-instance-properties) objects; each change is a defined, non-null object, see `Change` definition below 52 | - options is an _object_, optional 53 | 54 | ```javascript 55 | function personUIObserver(changes) { 56 | changes.forEach(change => { 57 | console.log(change.type); 58 | console.log(change.path); 59 | console.log(change.value); 60 | console.log(change.oldValue); 61 | }); 62 | } 63 | ... 64 | // following the observablePerson example from above 65 | Observable.observe(observablePerson, personUIObserver, options); // options is optional 66 | 67 | const observableAddress = observablePerson.address; 68 | Observable.observe(observableAddress, personUIObserver); // nested objects are observables too 69 | 70 | observablePerson.address = {}; // see below 71 | ``` 72 | 73 | > Attention! Observation set on the nested objects, like `address` in the example above, 'sticks' to that object. So if one replaces the nested object of the observable graph (see the last line of code above), observer callbacks __are NOT__ moved to the new object, they stick to the old one and continue to live there - think of detaching/replacing a sub-graph from the parent. 74 | 75 | ### `Observable.`__`unobserve([callback[, callback]+])`__ 76 | - `observable` MUST be an instance of `Observable` (see `from`) 77 | - receives a _function/s_ which previously was/were registered as an observer/s and removes it/them. If _no arguments_ passed, all observers will be removed. 78 | 79 | ```javascript 80 | Observable.unobserve(observablePerson, personUIObserver); 81 | // or 82 | Observable.unobserve(observablePerson); 83 | 84 | // same applies to the nested 85 | Observable.unobserve(observableAddress); 86 | ``` 87 | 88 | ## Observation options 89 | If/When provided, `options` parameter MUST contain ONLY one of the properties below, no 'unknown' properties allowed. 90 | 91 | In order to fail-fast and prevent unexpected mess down the hill, incorrect observation options will throw. 92 | 93 | - __`path`__ - non-empty string; specific path to observe, only a changes of this exact path will be notified; [details here](filter-paths.md) 94 | 95 | - __`pathsOf`__ - string, MAY be empty; direct properties of the specified path will be notified; [details here](filter-paths.md) 96 | 97 | - __`pathsFrom`__ - non-empty string, any changes from the specified path and deeper will be delivered to the observer; [details here](filter-paths.md) 98 | 99 | ## `Change` instance properties 100 | 101 | - __`type`__ - one of the following: `insert`, `update`, `delete`, `shuffle` or `reverse` 102 | - __`path`__ - path to the changed property represented as an __Array__ of nodes 103 | - __`value`__ - new value; `undefined` in `delete`, `shuffle` and `reverse` changes 104 | - __`oldValue`__ - old value; `undefined` in `insert`, `shuffle` or `reverse` changes 105 | - __`object`__ - an immediate subject of change, property of which has been changed (ES6 module distro ONLY) -------------------------------------------------------------------------------- /tests/workers/perf-async-test-a.js: -------------------------------------------------------------------------------- 1 | import { Observable } from '../../src/object-observer.js'; 2 | 3 | export default async setup => { 4 | const { 5 | TOLERANCE_MULTIPLIER, 6 | CREATE_ITERATIONS, 7 | MUTATE_ITERATIONS, 8 | OBJECT_CREATION_TRSHLD, 9 | PRIMITIVE_DEEP_MUTATION_TRSHLD, 10 | PRIMITIVE_DEEP_ADDITION_TRSHLD, 11 | PRIMITIVE_DEEP_DELETION_TRSHLD 12 | } = setup; 13 | 14 | let ttl, avg; 15 | const 16 | o = { 17 | name: 'Anna Guller', 18 | accountCreated: new Date(), 19 | age: 20, 20 | address: { 21 | city: 'Dreamland', 22 | street: { 23 | name: 'Hope', 24 | apt: 123 25 | } 26 | }, 27 | orders: [] 28 | }; 29 | let po, 30 | changesCountA, 31 | changesCountB, 32 | started, 33 | ended; 34 | 35 | // creation of Observable 36 | console.info(`[async] creating ${CREATE_ITERATIONS} observables from object...`); 37 | started = performance.now(); 38 | for (let i = 0; i < CREATE_ITERATIONS; i++) { 39 | po = Observable.from(o, { async: true }); 40 | } 41 | ended = performance.now(); 42 | ttl = ended - started; 43 | avg = ttl / CREATE_ITERATIONS; 44 | console.info(`... [async] create of ${CREATE_ITERATIONS} observables done: total - ${ttl.toFixed(2)}ms, average - ${avg.toFixed(4)}ms`); 45 | if (avg > OBJECT_CREATION_TRSHLD * TOLERANCE_MULTIPLIER) { 46 | throw new Error(`create perf assert failed, expected at most ${OBJECT_CREATION_TRSHLD * TOLERANCE_MULTIPLIER}, got ${avg}`); 47 | } else { 48 | console.info(`CREATE [async]: expected - ${OBJECT_CREATION_TRSHLD}, measured - ${avg.toFixed(4)}: PASSED`); 49 | } 50 | 51 | // add listeners/callbacks 52 | Observable.observe(po, changes => changesCountA += changes.length); 53 | Observable.observe(po, changes => changesCountB += changes.length); 54 | 55 | // mutation of existing property 56 | changesCountA = 0; 57 | changesCountB = 0; 58 | console.info(`[async] performing ${MUTATE_ITERATIONS} deep (x3) primitive mutations...`); 59 | started = performance.now(); 60 | for (let i = 0; i < MUTATE_ITERATIONS; i++) { 61 | po.address.street.apt = i; 62 | } 63 | await new Promise(r => setTimeout(r, 0)); 64 | ended = performance.now(); 65 | ttl = ended - started; 66 | avg = ttl / MUTATE_ITERATIONS; 67 | if (changesCountA !== MUTATE_ITERATIONS) throw new Error(`expected ${MUTATE_ITERATIONS}, got ${changesCountA}`); 68 | if (changesCountA !== MUTATE_ITERATIONS) throw new Error(`expected ${MUTATE_ITERATIONS}, got ${changesCountB}`); 69 | console.info(`... [async] mutate of ${MUTATE_ITERATIONS} X3 deep done: total - ${ttl.toFixed(2)}ms, average - ${avg.toFixed(4)}ms`); 70 | if (avg > PRIMITIVE_DEEP_MUTATION_TRSHLD * TOLERANCE_MULTIPLIER) { 71 | throw new Error(`mutate perf assert failed, expected at most ${PRIMITIVE_DEEP_MUTATION_TRSHLD * TOLERANCE_MULTIPLIER}, got ${avg}`); 72 | } else { 73 | console.info(`UPDATE [async]: expected - ${PRIMITIVE_DEEP_MUTATION_TRSHLD}, measured - ${avg.toFixed(4)}: PASSED`); 74 | } 75 | 76 | // adding new property 77 | changesCountA = 0; 78 | changesCountB = 0; 79 | console.info(`[async] performing ${MUTATE_ITERATIONS} deep (x3) primitive additions...`); 80 | started = performance.now(); 81 | for (let i = 0; i < MUTATE_ITERATIONS; i++) { 82 | po.address.street[i] = i; 83 | } 84 | await new Promise(r => setTimeout(r, 0)); 85 | ended = performance.now(); 86 | ttl = ended - started; 87 | avg = ttl / MUTATE_ITERATIONS; 88 | if (changesCountA !== MUTATE_ITERATIONS) throw new Error(`expected ${MUTATE_ITERATIONS}, got ${changesCountA}`); 89 | if (changesCountA !== MUTATE_ITERATIONS) throw new Error(`expected ${MUTATE_ITERATIONS}, got ${changesCountB}`); 90 | console.info(`... [async] add of ${MUTATE_ITERATIONS} X3 deep done: total - ${ttl.toFixed(2)}ms, average - ${avg.toFixed(4)}ms`); 91 | if (avg > PRIMITIVE_DEEP_ADDITION_TRSHLD * TOLERANCE_MULTIPLIER) { 92 | throw new Error(`add perf assert failed, expected at most ${PRIMITIVE_DEEP_ADDITION_TRSHLD * TOLERANCE_MULTIPLIER}, got ${avg}`); 93 | } else { 94 | console.info(`INSERT [async]: expected - ${PRIMITIVE_DEEP_ADDITION_TRSHLD}, measured - ${avg.toFixed(4)}: PASSED`); 95 | } 96 | 97 | // removing new property 98 | changesCountA = 0; 99 | changesCountB = 0; 100 | console.info(`[async] performing ${MUTATE_ITERATIONS} deep (x3) primitive deletions...`); 101 | started = performance.now(); 102 | for (let i = 0; i < MUTATE_ITERATIONS; i++) { 103 | delete po.address.street[i]; 104 | } 105 | await new Promise(r => setTimeout(r, 0)); 106 | ended = performance.now(); 107 | ttl = ended - started; 108 | avg = ttl / MUTATE_ITERATIONS; 109 | if (changesCountA !== MUTATE_ITERATIONS) throw new Error(`expected ${MUTATE_ITERATIONS}, got ${changesCountA}`); 110 | if (changesCountA !== MUTATE_ITERATIONS) throw new Error(`expected ${MUTATE_ITERATIONS}, got ${changesCountB}`); 111 | console.info(`... [async] delete of ${MUTATE_ITERATIONS} X3 deep done: total - ${ttl.toFixed(2)}ms, average - ${avg.toFixed(4)}ms`); 112 | if (avg > PRIMITIVE_DEEP_DELETION_TRSHLD * TOLERANCE_MULTIPLIER) { 113 | throw new Error(`delete perf assert failed, expected at most ${PRIMITIVE_DEEP_DELETION_TRSHLD * TOLERANCE_MULTIPLIER}, got ${avg}`); 114 | } else { 115 | console.info(`DELETE [async]: expected - ${PRIMITIVE_DEEP_DELETION_TRSHLD}, measured - ${avg.toFixed(4)}: PASSED`); 116 | } 117 | }; -------------------------------------------------------------------------------- /tests/observe-specific-paths.js: -------------------------------------------------------------------------------- 1 | import { test } from '@gullerya/just-test'; 2 | import { assert } from '@gullerya/just-test/assert'; 3 | import { Observable } from '../src/object-observer.js'; 4 | 5 | test('baseline - negative - path not a string', () => { 6 | const oo = Observable.from({}); 7 | assert.throws( 8 | () => Observable.observe(oo, () => { }, { path: 4 }), 9 | '"path" option, if/when provided, MUST be a non-empty string' 10 | ); 11 | }); 12 | 13 | test('baseline - negative - path empty', () => { 14 | const oo = Observable.from({}); 15 | assert.throws( 16 | () => Observable.observe(oo, () => { }, { path: '' }), 17 | '"path" option, if/when provided, MUST be a non-empty string' 18 | ); 19 | }); 20 | 21 | test('baseline - negative - pathsFrom not a string', () => { 22 | const oo = Observable.from({}); 23 | assert.throws( 24 | () => Observable.observe(oo, () => { }, { pathsFrom: 4 }), 25 | '"pathsFrom" option, if/when provided, MUST be a non-empty string' 26 | ); 27 | }); 28 | 29 | test('baseline - negative - pathsFrom empty', () => { 30 | const oo = Observable.from({}); 31 | assert.throws( 32 | () => Observable.observe(oo, () => { }, { pathsFrom: '' }), 33 | '"pathsFrom" option, if/when provided, MUST be a non-empty string' 34 | ); 35 | }); 36 | 37 | test('baseline - negative - no pathsFrom when path present', () => { 38 | const oo = Observable.from({}); 39 | assert.throws( 40 | () => Observable.observe(oo, () => { }, { path: 'some', pathsFrom: 'else' }), 41 | '"pathsFrom" option MAY NOT be specified together with' 42 | ); 43 | }); 44 | 45 | test('baseline - negative - no foreign options (pathFrom)', () => { 46 | const oo = Observable.from({}); 47 | assert.throws( 48 | () => Observable.observe(oo, () => { }, { pathFrom: 'something' }), 49 | 'is/are not a valid observer option/s' 50 | ); 51 | }); 52 | 53 | test('observe paths of - negative a', () => { 54 | const oo = Observable.from({}); 55 | assert.throws( 56 | () => Observable.observe(oo, () => { }, { pathsOf: 4 }), 57 | '"pathsOf" option, if/when provided, MUST be a string (MAY be empty)' 58 | ); 59 | }); 60 | 61 | test('observe paths of - negative b', () => { 62 | const oo = Observable.from({}); 63 | assert.throws( 64 | () => Observable.observe(oo, () => { }, { path: 'inner.prop', pathsOf: 'some.thing' }), 65 | '"pathsOf" option MAY NOT be specified together with "path" option' 66 | ); 67 | }); 68 | 69 | test('baseline - no options / empty options', () => { 70 | const 71 | oo = Observable.from({ inner: { prop: 'more' } }), 72 | observer = changes => (counter += changes.length); 73 | let counter = 0; 74 | 75 | // null is valid 76 | Observable.observe(oo, observer, null); 77 | oo.inner.prop = 'else'; 78 | 79 | assert.strictEqual(counter, 1); 80 | Observable.unobserve(oo, observer); 81 | }); 82 | 83 | test('baseline - empty options is valid', () => { 84 | const 85 | oo = Observable.from({ inner: { prop: 'more' } }), 86 | observer = changes => (counter += changes.length); 87 | let counter = 0; 88 | Observable.observe(oo, observer, {}); 89 | oo.inner.prop = 'even'; 90 | 91 | assert.strictEqual(counter, 1); 92 | Observable.unobserve(oo, observer); 93 | }); 94 | 95 | test('observe specific path', () => { 96 | const oo = Observable.from({ inner: { prop: 'more' } }); 97 | let callbackCalls = 0, 98 | changesCounter = 0; 99 | 100 | Observable.observe(oo, changes => { 101 | callbackCalls++; 102 | changesCounter += changes.length; 103 | }, { path: 'inner' }); 104 | 105 | oo.newProp = 'non-relevant'; 106 | oo.inner.other = 'non-relevant'; 107 | oo.inner = {}; 108 | 109 | assert.strictEqual(changesCounter, 1); 110 | assert.strictEqual(callbackCalls, 1); 111 | }); 112 | 113 | test('observe paths from .. and deeper', () => { 114 | const oo = Observable.from({ inner: { prop: 'more', nested: { text: 'text' } } }); 115 | let counter = 0; 116 | 117 | Observable.observe(oo, changes => { counter += changes.length; }, { pathsFrom: 'inner.prop' }); 118 | oo.nonRelevant = 'non-relevant'; 119 | oo.inner.also = 'non-relevant'; 120 | oo.inner.prop = 'relevant'; 121 | oo.inner.prop = {}; 122 | oo.inner.prop.deepRelevant = 'again'; 123 | assert.strictEqual(counter, 3); 124 | }); 125 | 126 | test('observe paths of - inner case', () => { 127 | const oo = Observable.from({ inner: { prop: 'more', nested: { text: 'text' } } }); 128 | let counter = 0; 129 | Observable.observe(oo, changes => { counter += changes.length; }, { pathsOf: 'inner.nested' }); 130 | oo.nonRelevant = 'non-relevant'; 131 | oo.inner.also = 'non-relevant'; 132 | oo.inner.nested.text = 'relevant'; 133 | oo.inner.nested.else = 'also relevant'; 134 | oo.inner.nested = { nesnes: { test: 'non-relevant' } }; 135 | oo.inner.nested.nesnes.test = 'non-relevant'; 136 | assert.strictEqual(counter, 2); 137 | }); 138 | 139 | test('observe paths of - array - property of same depth updated', () => { 140 | const oo = Observable.from({ array: [1, 2, 3], prop: { inner: 'value' } }); 141 | let counter = 0; 142 | Observable.observe(oo, changes => { counter += changes.length; }, { pathsOf: 'array' }); 143 | oo.nonRelevant = 'non-relevant'; 144 | oo.prop.inner = 'non-relevant'; 145 | oo.prop = { newObj: { test: 'non-relevant' } }; 146 | oo.array.pop(); 147 | oo.array.push({ newObj: { test: 'relevant' } }); 148 | oo.array[2].newObj.test = 'non-relevant'; 149 | assert.equal(counter, 2); 150 | }); 151 | 152 | test('observe paths of - root case', () => { 153 | const oo = Observable.from({ inner: { prop: 'more', nested: { text: 'text' } } }); 154 | let counter = 0; 155 | Observable.observe(oo, changes => { counter += changes.length; }, { pathsOf: '' }); 156 | oo.relevant = 'relevant'; 157 | oo.inner.also = 'non-relevant'; 158 | oo.inner = { newObj: { test: 'relevant' } }; 159 | oo.inner.newObj.test = 'non-relevant'; 160 | assert.equal(counter, 2); 161 | }); 162 | 163 | test('observe paths of - root case - array sort', () => { 164 | const oo = Observable.from([1, 3, 2, 4, 9]); 165 | let counter = 0; 166 | Observable.observe(oo, changes => { counter += changes.length; }, { pathsOf: '' }); 167 | oo.sort(); 168 | assert.isTrue(oo[0] === 1 && oo[1] === 2 && oo[2] === 3 && oo[3] === 4 && oo[4] === 9); 169 | assert.equal(counter, 1); 170 | }); 171 | 172 | test('observe paths of - root case - array reverse', () => { 173 | const oo = Observable.from([1, 2, 3, 4, 9]); 174 | let counter = 0; 175 | Observable.observe(oo, changes => { counter += changes.length; }, { pathsOf: '' }); 176 | oo.reverse(); 177 | assert.isTrue(oo[0] === 9 && oo[1] === 4 && oo[2] === 3 && oo[3] === 2 && oo[4] === 1); 178 | assert.equal(counter, 1); 179 | }); 180 | -------------------------------------------------------------------------------- /tests/object-observer-objects.js: -------------------------------------------------------------------------------- 1 | import { test } from '@gullerya/just-test'; 2 | import { assert } from '@gullerya/just-test/assert'; 3 | import { Observable } from '../src/object-observer.js'; 4 | 5 | test('creating observable leaves original object as is', () => { 6 | const person = { 7 | name: 'name', 8 | age: 7 9 | }; 10 | const 11 | address = { city: 'city' }, 12 | street = { name: 'street', apt: 234 }; 13 | 14 | address.street = street; 15 | person.address = address; 16 | 17 | Observable.from(person); 18 | 19 | assert.deepStrictEqual(person.address, address); 20 | }); 21 | 22 | test('creating observable preserves original object keys order', () => { 23 | const person = { 24 | name: 'name', 25 | age: 7, 26 | street: 'street', 27 | block: 9, 28 | apt: 1 29 | }; 30 | const oPerson = Observable.from(person); 31 | const sKeys = Object.keys(person); 32 | const oKeys = Object.keys(oPerson); 33 | 34 | assert.strictEqual(sKeys.length, oKeys.length); 35 | for (const [i, key] of Object.entries(sKeys)) { 36 | assert.strictEqual(key, oKeys[i]); 37 | } 38 | }); 39 | 40 | test('plain object operations', () => { 41 | const o = { 42 | name: 'name', 43 | age: 7, 44 | address: null 45 | } 46 | const 47 | events = [], 48 | tmpAddress = { street: 'some' }; 49 | 50 | const po = Observable.from(o); 51 | Observable.observe(po, changes => { 52 | [].push.apply(events, changes); 53 | }); 54 | 55 | const v1 = po.name = 'new name'; 56 | const v2 = po.age = 9; 57 | const v3 = po.address = tmpAddress; 58 | assert.strictEqual(v1, 'new name'); 59 | assert.strictEqual(v2, 9); 60 | assert.deepStrictEqual(v3, tmpAddress); 61 | assert.strictEqual(events.length, 3); 62 | assert.deepStrictEqual(events[0], { type: 'update', path: ['name'], value: 'new name', oldValue: 'name', object: po }); 63 | assert.deepStrictEqual(events[1], { type: 'update', path: ['age'], value: 9, oldValue: 7, object: po }); 64 | assert.deepStrictEqual(events[2], { type: 'update', path: ['address'], value: po.address, oldValue: null, object: po }); 65 | 66 | const v4 = po.address = null; 67 | const v5 = po.sex = 'male'; 68 | delete po.sex; 69 | assert.strictEqual(v4, null); 70 | assert.strictEqual(v5, 'male'); 71 | assert.strictEqual(events.length, 6); 72 | assert.deepStrictEqual(events[3], { type: 'update', path: ['address'], value: null, oldValue: { street: 'some' }, object: po }); 73 | assert.deepStrictEqual(events[4], { type: 'insert', path: ['sex'], value: 'male', oldValue: undefined, object: po }); 74 | assert.deepStrictEqual(events[5], { type: 'delete', path: ['sex'], value: undefined, oldValue: 'male', object: po }); 75 | }); 76 | 77 | test('sub tree object operations', () => { 78 | const person = { 79 | name: 'name', 80 | age: 7, 81 | address: null, 82 | addressB: { 83 | street: { 84 | name: 'street name', 85 | apt: 123 86 | } 87 | } 88 | }; 89 | const 90 | events = [], 91 | newAddress = {}; 92 | 93 | const po = Observable.from(person); 94 | Observable.observe(po, changes => { 95 | [].push.apply(events, changes); 96 | }); 97 | 98 | po.address = newAddress; 99 | assert.strictEqual(events.length, 1); 100 | assert.deepStrictEqual(events[0], { type: 'update', path: ['address'], value: po.address, oldValue: null, object: po }); 101 | 102 | po.address.street = 'street'; 103 | po.addressB.street.name = 'new street name'; 104 | assert.strictEqual(events.length, 3); 105 | assert.deepStrictEqual(events[1], { type: 'insert', path: ['address', 'street'], value: 'street', oldValue: undefined, object: po.address }); 106 | assert.deepStrictEqual(events[2], { type: 'update', path: ['addressB', 'street', 'name'], value: 'new street name', oldValue: 'street name', object: po.addressB.street }); 107 | }); 108 | 109 | test('subgraph correctly detached when replaced', () => { 110 | const 111 | oo = Observable.from({ inner: {} }), 112 | events = [], 113 | eventsA = [], 114 | eventsB = [], 115 | inner = oo.inner; 116 | 117 | Observable.observe(oo, changes => Array.prototype.push.apply(events, changes)); 118 | Observable.observe(inner, changes => Array.prototype.push.apply(eventsA, changes)); 119 | 120 | inner.some = 'text'; 121 | assert.strictEqual(1, events.length); 122 | assert.strictEqual(1, eventsA.length); 123 | 124 | oo.inner = {}; 125 | Observable.observe(oo.inner, changes => Array.prototype.push.apply(eventsB, changes)); 126 | assert.strictEqual(2, events.length); 127 | assert.strictEqual(1, eventsA.length); 128 | 129 | inner.some = 'other text'; 130 | assert.strictEqual(2, events.length); 131 | assert.strictEqual(2, eventsA.length); 132 | assert.strictEqual(0, eventsB.length); 133 | 134 | oo.inner.some = 'yet another'; 135 | assert.strictEqual(3, events.length); 136 | assert.strictEqual(2, eventsA.length); 137 | assert.strictEqual(1, eventsB.length); 138 | }); 139 | 140 | test('subgraph correctly detached when deleted', () => { 141 | const 142 | oo = Observable.from({ inner: {} }), 143 | events = [], 144 | eventsA = [], 145 | inner = oo.inner; 146 | 147 | Observable.observe(oo, changes => Array.prototype.push.apply(events, changes)); 148 | Observable.observe(inner, changes => Array.prototype.push.apply(eventsA, changes)); 149 | 150 | inner.some = 'text'; 151 | assert.strictEqual(1, events.length); 152 | assert.strictEqual(1, eventsA.length); 153 | 154 | delete oo.inner; 155 | 156 | inner.some = 'other text'; 157 | assert.strictEqual(2, events.length); 158 | assert.strictEqual(2, eventsA.length); 159 | }); 160 | 161 | test('subgraph proxy correctly processed when callbacks not yet set', () => { 162 | const 163 | oo = Observable.from({ 164 | inner: {} 165 | }); 166 | const 167 | callback = function (changes) { 168 | [].push.apply(events, changes); 169 | }; 170 | let events = []; 171 | 172 | Observable.observe(oo, callback); 173 | oo.inner.some = 'text'; 174 | assert.strictEqual(events.length, 1); 175 | Observable.unobserve(oo, callback); 176 | 177 | oo.inner = {}; 178 | events = []; 179 | Observable.observe(oo, callback); 180 | oo.inner.other = 'text'; 181 | assert.strictEqual(events.length, 1); 182 | }); 183 | 184 | test('Object.assign with multiple properties - sync yields many callbacks', () => { 185 | const 186 | observable = Observable.from({}), 187 | newData = { a: 1, b: 2, c: 3 }, 188 | events = []; 189 | let callbacks = 0; 190 | Observable.observe(observable, changes => { 191 | callbacks++; 192 | events.push.apply(events, changes); 193 | }); 194 | 195 | Object.assign(observable, newData); 196 | observable.a = 4; 197 | 198 | assert.strictEqual(events.length, 4); 199 | assert.strictEqual(callbacks, 4); 200 | // TODO: add more assertions 201 | }); -------------------------------------------------------------------------------- /tests/object-observer-arrays-typed.js: -------------------------------------------------------------------------------- 1 | import { test } from '@gullerya/just-test'; 2 | import { assert } from '@gullerya/just-test/assert'; 3 | import { Observable } from '../src/object-observer.js'; 4 | 5 | test('typed array reverse - Int8Array', () => { 6 | const 7 | pa = Observable.from(new Int8Array([1, 2, 3])), 8 | events = []; 9 | 10 | Observable.observe(pa, eventsList => { 11 | [].push.apply(events, eventsList); 12 | }); 13 | 14 | const reversed = pa.reverse(); 15 | 16 | assert.strictEqual(reversed, pa); 17 | assert.strictEqual(events.length, 1); 18 | assert.deepStrictEqual(events[0], { type: 'reverse', path: [], value: undefined, oldValue: undefined, object: pa }); 19 | assert.deepStrictEqual(pa, new Int8Array([3, 2, 1])); 20 | }); 21 | 22 | test('typed array sort - Int16Array', () => { 23 | const 24 | pa = Observable.from(new Int16Array([3, 2, 1])), 25 | events = []; 26 | 27 | Observable.observe(pa, eventsList => { 28 | [].push.apply(events, eventsList); 29 | }); 30 | 31 | let sorted = pa.sort(); 32 | 33 | assert.strictEqual(sorted, pa); 34 | assert.strictEqual(events.length, 1); 35 | assert.deepStrictEqual(events[0], { type: 'shuffle', path: [], value: undefined, oldValue: undefined, object: pa }); 36 | assert.deepStrictEqual(pa, new Int16Array([1, 2, 3])); 37 | 38 | sorted = pa.sort((a, b) => { 39 | return a < b ? 1 : -1; 40 | }); 41 | assert.strictEqual(sorted, pa); 42 | assert.strictEqual(events.length, 2); 43 | assert.deepStrictEqual(events[0], { type: 'shuffle', path: [], value: undefined, oldValue: undefined, object: pa }); 44 | assert.deepStrictEqual(pa, new Int16Array([3, 2, 1])); 45 | }); 46 | 47 | test('typed array fill - Int32Array', () => { 48 | const 49 | pa = Observable.from(new Int32Array([1, 2, 3])), 50 | events = []; 51 | 52 | Observable.observe(pa, eventsList => { 53 | [].push.apply(events, eventsList); 54 | }); 55 | 56 | const filled = pa.fill(256); 57 | assert.strictEqual(filled, pa); 58 | assert.strictEqual(events.length, 3); 59 | assert.deepStrictEqual(events[0], { type: 'update', path: [0], value: 256, oldValue: 1, object: pa }); 60 | assert.deepStrictEqual(events[1], { type: 'update', path: [1], value: 256, oldValue: 2, object: pa }); 61 | assert.deepStrictEqual(events[2], { type: 'update', path: [2], value: 256, oldValue: 3, object: pa }); 62 | events.splice(0); 63 | 64 | pa.fill(1024, 1, 3); 65 | assert.strictEqual(events.length, 2); 66 | assert.deepStrictEqual(events[0], { type: 'update', path: [1], value: 1024, oldValue: 256, object: pa }); 67 | assert.deepStrictEqual(events[1], { type: 'update', path: [2], value: 1024, oldValue: 256, object: pa }); 68 | events.splice(0); 69 | 70 | pa.fill(9024, -1, 3); 71 | assert.strictEqual(events.length, 1); 72 | assert.deepStrictEqual(events[0], { type: 'update', path: [2], value: 9024, oldValue: 1024, object: pa }); 73 | events.splice(0); 74 | 75 | // simulating insertion of a new item into array (fill does not extend an array, so we may do it only on internal items) 76 | pa[1] = 0; 77 | pa.fill(12056, 1, 2); 78 | assert.strictEqual(events.length, 2); 79 | assert.deepStrictEqual(events[0], { type: 'update', path: ['1'], value: 0, oldValue: 1024, object: pa }); 80 | assert.deepStrictEqual(events[1], { type: 'update', path: [1], value: 12056, oldValue: 0, object: pa }); 81 | }); 82 | 83 | test('typed array set - Float32Array', () => { 84 | const 85 | pa = Observable.from(new Float32Array(8)), 86 | events = []; 87 | 88 | Observable.observe(pa, eventsList => { 89 | [].push.apply(events, eventsList); 90 | }); 91 | 92 | // basic set 93 | pa.set([1, 2, 3], 3); 94 | assert.strictEqual(events.length, 3); 95 | assert.deepStrictEqual(events[0], { type: 'update', path: [3], value: 1, oldValue: 0, object: pa }); 96 | assert.deepStrictEqual(events[1], { type: 'update', path: [4], value: 2, oldValue: 0, object: pa }); 97 | assert.deepStrictEqual(events[2], { type: 'update', path: [5], value: 3, oldValue: 0, object: pa }); 98 | events.splice(0); 99 | 100 | // set no offset - effectively 0 101 | pa.set([1, 1, 1]); 102 | assert.strictEqual(events.length, 3); 103 | assert.deepStrictEqual(events[0], { type: 'update', path: [0], value: 1, oldValue: 0, object: pa }); 104 | assert.deepStrictEqual(events[1], { type: 'update', path: [1], value: 1, oldValue: 0, object: pa }); 105 | assert.deepStrictEqual(events[2], { type: 'update', path: [2], value: 1, oldValue: 0, object: pa }); 106 | events.splice(0); 107 | 108 | // set from TypedArray 109 | pa.set(new Int8Array([5, 6, 7]), 2); 110 | assert.strictEqual(events.length, 3); 111 | assert.deepStrictEqual(events[0], { type: 'update', path: [2], value: 5, oldValue: 1, object: pa }); 112 | assert.deepStrictEqual(events[1], { type: 'update', path: [3], value: 6, oldValue: 1, object: pa }); 113 | assert.deepStrictEqual(events[2], { type: 'update', path: [4], value: 7, oldValue: 2, object: pa }); 114 | events.splice(0); 115 | }); 116 | 117 | test('typed array copyWithin - Float64Array', () => { 118 | const 119 | pa = Observable.from(new Float64Array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])), 120 | events = []; 121 | 122 | Observable.observe(pa, eventsList => { 123 | [].push.apply(events, eventsList); 124 | }); 125 | 126 | // basic case 127 | let copied = pa.copyWithin(5, 7, 9); 128 | assert.strictEqual(pa, copied); 129 | assert.strictEqual(events.length, 2); 130 | assert.deepStrictEqual(events[0], { type: 'update', path: [5], value: 7, oldValue: 5, object: pa }); 131 | assert.deepStrictEqual(events[1], { type: 'update', path: [6], value: 8, oldValue: 6, object: pa }); 132 | events.splice(0); 133 | 134 | // negative dest, missing end 135 | copied = pa.copyWithin(-2, 6); 136 | assert.strictEqual(pa, copied); 137 | assert.strictEqual(events.length, 1); 138 | // we do not expect of 8, since 8 replaced with 8 139 | assert.deepStrictEqual(events[0], { type: 'update', path: [9], value: 7, oldValue: 9, object: pa }); 140 | events.splice(0); 141 | 142 | // positive dest, missing start, end 143 | copied = pa.copyWithin(7); 144 | assert.strictEqual(pa, copied); 145 | assert.strictEqual(events.length, 3); 146 | assert.deepStrictEqual(events[0], { type: 'update', path: [7], value: 0, oldValue: 7, object: pa }); 147 | assert.deepStrictEqual(events[1], { type: 'update', path: [8], value: 1, oldValue: 8, object: pa }); 148 | assert.deepStrictEqual(events[2], { type: 'update', path: [9], value: 2, oldValue: 7, object: pa }); 149 | events.splice(0); 150 | }); 151 | 152 | test('typed array as nested - Uint8Array', () => { 153 | const 154 | po = Observable.from({ a: new Uint8Array([1, 2, 3]) }), 155 | events = []; 156 | 157 | Observable.observe(po, eventsList => { 158 | [].push.apply(events, eventsList); 159 | }); 160 | 161 | po.a[1] = 7; 162 | assert.strictEqual(events.length, 1); 163 | assert.deepStrictEqual(events[0], { type: 'update', path: ['a', '1'], value: 7, oldValue: 2, object: po.a }); 164 | }); 165 | -------------------------------------------------------------------------------- /tests/observable-nested.js: -------------------------------------------------------------------------------- 1 | import { test } from '@gullerya/just-test'; 2 | import { assert } from '@gullerya/just-test/assert'; 3 | import { Observable } from '../src/object-observer.js'; 4 | 5 | test('nested of observable should be observable too', () => { 6 | const oo = Observable.from({ 7 | user: { 8 | address: { 9 | street: 'street', 10 | block: 'block', 11 | city: 'city' 12 | } 13 | } 14 | }); 15 | 16 | assert.isTrue(Observable.isObservable(oo)); 17 | assert.isTrue(Observable.isObservable(oo.user)); 18 | assert.isTrue(Observable.isObservable(oo.user.address)); 19 | assert.isFalse(Observable.isObservable(oo.user.address.street)); 20 | }); 21 | 22 | test('observable from nested stays the same object reference', () => { 23 | const oo = Observable.from({ 24 | user: { 25 | address: { 26 | street: 'street', 27 | block: 'block', 28 | city: 'city' 29 | } 30 | } 31 | }); 32 | const oou = Observable.from(oo.user), 33 | ooua = Observable.from(oo.user.address); 34 | 35 | assert.equal(oo.user, oou); 36 | assert.equal(oo.user.address, oou.address); 37 | assert.equal(oo.user.address, ooua); 38 | assert.equal(oou.address, ooua); 39 | }); 40 | 41 | test('observable from nested can be observed', () => { 42 | const oo = Observable.from({ 43 | user: { 44 | address: { 45 | street: 'street', 46 | block: 'block', 47 | city: 'city' 48 | } 49 | } 50 | }); 51 | const oou = Observable.from(oo.user), 52 | ooua = Observable.from(oo.user.address); 53 | 54 | assert.isTrue(Observable.isObservable(oou)); 55 | assert.isTrue(Observable.isObservable(ooua)); 56 | 57 | const rootEvents = []; 58 | const rootObs = changes => Array.prototype.push.apply(rootEvents, changes); 59 | const nestedEvents = []; 60 | const nestedObs = changes => Array.prototype.push.apply(nestedEvents, changes); 61 | 62 | Observable.observe(oou, nestedObs); 63 | oou.address.city = 'cityA'; 64 | assert.equal(1, nestedEvents.length); 65 | 66 | Observable.observe(oo, rootObs); 67 | oo.user.address.city = 'cityB'; 68 | assert.equal(1, rootEvents.length); 69 | assert.equal(2, nestedEvents.length); 70 | 71 | oou.address.city = 'cityC'; 72 | assert.equal(2, rootEvents.length); 73 | assert.equal(3, nestedEvents.length); 74 | 75 | Observable.unobserve(oou, nestedObs); 76 | oou.address.city = 'cityD'; 77 | assert.equal(3, rootEvents.length); 78 | assert.equal(3, nestedEvents.length); 79 | 80 | Observable.observe(oou, nestedObs); 81 | oou.address.city = 'cityE'; 82 | assert.equal(4, rootEvents.length); 83 | assert.equal(4, nestedEvents.length); 84 | 85 | Observable.unobserve(oou); 86 | oou.address.city = 'cityF'; 87 | assert.equal(5, rootEvents.length); 88 | assert.equal(4, nestedEvents.length); 89 | }); 90 | 91 | test('nested observable should handle errors', () => { 92 | const oo = Observable.from({ 93 | user: { 94 | address: { 95 | street: 'street', 96 | block: 'block', 97 | city: 'city' 98 | } 99 | } 100 | }) 101 | const oou = Observable.from(oo.user); 102 | assert.throws( 103 | () => Observable.observe(oou, 'invalid observer'), 104 | 'observer MUST be a function' 105 | ); 106 | }); 107 | 108 | test('nested observable should handle duplicate', () => { 109 | const oo = Observable.from({ 110 | user: { 111 | address: { 112 | street: 'street', 113 | block: 'block', 114 | city: 'city' 115 | } 116 | } 117 | }); 118 | const 119 | oou = Observable.from(oo.user), 120 | events = [], 121 | observer = changes => Array.prototype.push.apply(events, changes); 122 | 123 | Observable.observe(oou, observer); 124 | Observable.observe(oou, observer); 125 | oou.address.street = 'streetA'; 126 | assert.equal(1, events.length); 127 | }); 128 | 129 | test('nested observable should provide correct path (relative to self)', () => { 130 | const oo = Observable.from({ 131 | user: { 132 | address: { 133 | street: 'street', 134 | block: 'block', 135 | city: 'city' 136 | } 137 | } 138 | }) 139 | const 140 | oou = Observable.from(oo.user), 141 | ooua = Observable.from(oo.user.address), 142 | events = [], 143 | eventsU = [], 144 | eventsUA = []; 145 | 146 | Observable.observe(oo, changes => Array.prototype.push.apply(events, changes)); 147 | Observable.observe(oou, changes => Array.prototype.push.apply(eventsU, changes)); 148 | Observable.observe(ooua, changes => Array.prototype.push.apply(eventsUA, changes)); 149 | 150 | ooua.street = 'streetA'; 151 | assert.equal(1, events.length); 152 | assert.equal('user.address.street', events[0].path.join('.')); 153 | assert.equal(1, eventsU.length); 154 | assert.equal('address.street', eventsU[0].path.join('.')); 155 | assert.equal(1, eventsUA.length); 156 | assert.equal('street', eventsUA[0].path.join('.')); 157 | }); 158 | 159 | test('nested observable should continue to function when detached', () => { 160 | const oo = Observable.from({ 161 | user: { 162 | address: { 163 | street: 'street', 164 | block: 'block', 165 | city: 'city' 166 | } 167 | } 168 | }); 169 | const 170 | oou = Observable.from(oo.user), 171 | ooua = Observable.from(oo.user.address), 172 | events = [], 173 | eventsU = [], 174 | eventsUA = []; 175 | 176 | Observable.observe(oo, changes => Array.prototype.push.apply(events, changes)); 177 | Observable.observe(oou, changes => Array.prototype.push.apply(eventsU, changes)); 178 | Observable.observe(ooua, changes => Array.prototype.push.apply(eventsUA, changes)); 179 | 180 | ooua.street = 'streetA'; 181 | assert.equal(1, events.length); 182 | assert.equal(1, eventsU.length); 183 | assert.equal(1, eventsUA.length); 184 | 185 | // dettaching user 186 | oo.user = {}; 187 | assert.equal(2, events.length); 188 | assert.equal(1, eventsU.length); 189 | assert.equal(1, eventsUA.length); 190 | 191 | ooua.street = 'streetB'; 192 | assert.equal(2, events.length); 193 | assert.equal(2, eventsU.length); 194 | assert.equal(2, eventsUA.length); 195 | 196 | // dettaching address 197 | oou.address = {}; 198 | ooua.street = 'streetC'; 199 | assert.equal(2, events.length); 200 | assert.equal(3, eventsU.length); 201 | assert.equal(3, eventsUA.length); 202 | }); 203 | 204 | test('nested observable is still cloned when moved', () => { 205 | const 206 | u = { user: { address: { street: 'street', block: 'block', city: 'city' } } }, 207 | oo = Observable.from([u, u]), 208 | oou = Observable.from(oo[0].user), 209 | ooua = Observable.from(oo[0].user.address), 210 | events = [], 211 | eventsU = [], 212 | eventsUA = []; 213 | 214 | Observable.observe(oo, changes => Array.prototype.push.apply(events, changes)); 215 | Observable.observe(oou, changes => Array.prototype.push.apply(eventsU, changes)); 216 | Observable.observe(ooua, changes => Array.prototype.push.apply(eventsUA, changes)); 217 | 218 | ooua.street = 'streetA'; 219 | assert.equal(1, events.length); 220 | assert.equal('0.user.address.street', events[0].path.join('.')); 221 | assert.equal(1, eventsU.length); 222 | assert.equal('address.street', eventsU[0].path.join('.')); 223 | assert.equal(1, eventsUA.length); 224 | assert.equal('street', eventsUA[0].path.join('.')); 225 | 226 | // moving the subgraph 227 | oo[1].user = oou; 228 | ooua.street = 'streetB'; 229 | assert.equal(3, events.length); 230 | assert.equal('0.user.address.street', events[2].path.join('.')); 231 | assert.equal(2, eventsU.length); 232 | assert.equal('address.street', eventsU[1].path.join('.')); 233 | assert.equal(2, eventsUA.length); 234 | assert.equal('street', eventsUA[1].path.join('.')); 235 | 236 | assert.isFalse(oo[0].user === oo[1].user); 237 | assert.isFalse(oo[0].user.address === oo[1].user.address); 238 | }); -------------------------------------------------------------------------------- /docs/changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | Change log of the `object-observer` by versions. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ## [6.1.4] - 2025-02-14 11 | ### Chore 12 | - updated dependencies 13 | 14 | ## [6.1.3] - 2023-11-28 15 | ### Fixed 16 | - [Issue no. 142](https://github.com/gullerya/object-observer/issues/142) - added TypeScript type to exports declaration 17 | ### Chore 18 | - updated some minor dependencies 19 | 20 | 21 | ## [6.1.1] - 2023-03-23 22 | ### Chore 23 | - updated the JustTest harness, the whole test suite is now running on NodeJS, Chromium, WebKit platforms 24 | 25 | ## [6.1.0] - 2023-03-22 26 | ### Chore 27 | - updated the JustTest harness, the whole test suite is now running on NodeJS, Chromium, WebKit platforms 28 | - introduced true JS privates instead of Symbol based ones 29 | 30 | ## [6.0.3] - 2023-03-01 31 | ### BREAKING CHANGE 32 | ### Chore 33 | - moved deployment to the scoped NPM package `@gullerya/object-observer` 34 | 35 | ## [5.1.7] - 2023-03-01 36 | ### Fixed 37 | - [Issue no. 129](https://github.com/gullerya/object-observer/issues/129) - graceful handling of the circular referenced inputs 38 | ### Chore 39 | - updated performance data on NodeJS 40 | - upgraded dependencies 41 | 42 | ## [5.1.6] - 2022-09-25 43 | ### Chore 44 | - upgraded dependencies 45 | 46 | ## [5.1.5] - 2022-09-14 47 | ### Chore 48 | - reduced dependencies (via reworking build flow) 49 | - improved CI 50 | 51 | ## [5.1.0] - 2022-09-07 52 | ### Added 53 | - [Issue no. 121](https://github.com/gullerya/object-observer/issues/121) - added commonjs module build 54 | ### Chore 55 | - upgraded dependencies 56 | 57 | ## [5.0.4] - 2022-07-02 58 | ### Chore 59 | - upgraded dependencies 60 | - [Issue no. 86](https://github.com/gullerya/object-observer/issues/86) - moved to the new JustTest testing framework 61 | 62 | ## [5.0.2] - 2022-05-09 63 | ### Chore 64 | - upgraded dependencies 65 | 66 | ## [5.0.0] - 2022-02-16 67 | ### Changed (breaking change) 68 | - [Issue no. 113](https://github.com/gullerya/object-observer/issues/113) - removed `observable`'s `observe` and `unobserve` in favor of the static counterparts from `Observable` namespace. 69 | 70 | ## [4.8.0] - 2022-02-12 71 | ### Added 72 | - [Issue no. 111](https://github.com/gullerya/object-observer/issues/111) - Added `observe` and `unobserve` methods as statics on the `Observable`. Those methods will be removed from the next major release (5) from the observable instance and only be available from `Observable` namespace. 73 | ### Chore 74 | - upgraded dependencies 75 | 76 | 77 | ## [4.7.2] - 2021-12-25 78 | ### Fixed 79 | - [Issue no. 106](https://github.com/gullerya/object-observer/issues/106) - Fixed TS definition of ChangeType (enum to type) 80 | - [Issue no. 107](https://github.com/gullerya/object-observer/issues/107) - Fixed TS definition of ObjectObserver.observe (options are optional) 81 | 82 | ## [4.7.1] - 2021-12-22 83 | ### Fixed 84 | - [Issue no. 104](https://github.com/gullerya/object-observer/issues/104) - Fixed TS definitions 85 | 86 | ## [4.7.0] - 2021-12-18 87 | ### Added 88 | - [Issue no. 102](https://github.com/gullerya/object-observer/issues/102) - Added TS definitions for convenience 89 | 90 | ## [4.6.6] - 2021-12-18 91 | ### Chore 92 | - [Issue no. 99](https://github.com/gullerya/object-observer/issues/99) - simplified CD flow 93 | 94 | ## [4.6.0] - 2021-11-19 95 | ### Changed 96 | - [Issue no. 97](https://github.com/gullerya/object-observer/issues/97) - removing the care for native objects, any but `Date`, due to seemingly non-relevancy (until proven otherwise); this effectively un-does issue #2 97 | 98 | ## [4.5.0] - 2021-11-13 99 | ### Fixed 100 | - [Issue no. 53](https://github.com/gullerya/object-observer/issues/53) - fixing failures on NodeJS due to `Blob` unavailability on global scope 101 | 102 | ## [4.4.0] - 2021-11-07 103 | ### Fixed 104 | - [Issue no. 93](https://github.com/gullerya/object-observer/issues/93) - `pathsOf` misbehave fixed 105 | ### Chore 106 | - dependencies updated 107 | - performance tuned up 108 | 109 | ## [4.3.2] - 2021-07-19 110 | ### Chore 111 | - dependencies updated 112 | 113 | ## [4.3.1] - 2021-06-15 114 | ### Chore 115 | - dependencies updated 116 | 117 | ## [4.3.0] - 2021-05-03 118 | ### Changed 119 | - [Issue no. 82](https://github.com/gullerya/object-observer/issues/82) - `object-observer` made cross-instance operable 120 | 121 | ## [4.2.4] - 2021-05-02 122 | ### Added 123 | - [Issue no. 79](https://github.com/gullerya/object-observer/issues/79) - added CodePen example for ObjectObserver API flavor 124 | - [Issue no. 81](https://github.com/gullerya/object-observer/issues/81) - added integrity checksums to the CDN flow and documentation 125 | 126 | ## [4.2.2] - 2021-04-23 127 | ### Added 128 | - [Issue no. 77](https://github.com/gullerya/object-observer/issues/77) - manual CI trigger for release 129 | ### Chore 130 | - documentation improved and updated 131 | - dependencies updated 132 | 133 | ## [4.2.1] - 2021-03-15 134 | ### Added 135 | - [Issue no. 73](https://github.com/gullerya/object-observer/issues/73) - added DOM-like API of `ObjectObserver` 136 | ### Chore 137 | - documentation improved and updated 138 | - dependencies updated 139 | 140 | ## [4.1.3] - 2021-02-01 141 | ### Added 142 | - [Issue no. 71](https://github.com/gullerya/object-observer/issues/71) - added CDN deployment 143 | 144 | ## [4.1.1] - 2021-01-16 145 | ### Fixed 146 | - `change` structure unified for all types of events 147 | - slightly improved performance 148 | ### Changed 149 | - [Issue no. 70](https://github.com/gullerya/object-observer/issues/70) - CI release automation flow improved 150 | - performance tests adjusted 151 | 152 | ## [4.0.4] - 2020-11-18 153 | ### Added 154 | - performance tests 155 | 156 | ## [4.0.3] - 2020-11-17 157 | ### Fixed 158 | - [Issue no. 65](https://github.com/gullerya/object-observer/issues/65) - fixed a broken keys order of the cloned observable 159 | ### Changed 160 | - dependencies updated 161 | 162 | ## [4.0.2] - 2020-10-23 163 | ### Added 164 | - security process to be used - [TideLift](https://tidelift.com/security) 165 | - added automated release CI flow 166 | ### Removed 167 | - [Issue no. 61](https://github.com/gullerya/object-observer/issues/61) - removed the CommonJS-fashioned NodeJS distribution 168 | 169 | ## [3.2.0] - 2020-09-03 170 | ### Added 171 | - [Issue no. 45](https://github.com/gullerya/object-observer/issues/45) - implemented async flavor of changes delivery on per Observable configuration basis; default behavior remained the same - synchronous 172 | - [Issue no. 51](https://github.com/gullerya/object-observer/issues/51) - batch delivery of `Object.assign` changes is enabled via the async opt-in, see issue #45 above 173 | 174 | ## [3.1.1] - 2020-09-03 175 | ### Fixed 176 | - [Issue no. 58](https://github.com/gullerya/object-observer/issues/58) - JSFiddle link to point to the latest 177 | 178 | ## [3.1.0] - 2020-08-23 179 | ### Changed 180 | - [Issue no. 55](https://github.com/gullerya/object-observer/issues/55) - enhanced documentation of observation options 181 | ### Fixed 182 | - [Issue no. 56](https://github.com/gullerya/object-observer/issues/56) - fixed handling of `pathsOf` option in case of `Array` massive mutations (`reverse`, `shuffle` events) 183 | ### Added 184 | - enhanced tests and fixed mis-implemented negative ones 185 | 186 | ## [3.0.3] - 2020-06-04 187 | ### Added 188 | - [Issue no. 46](https://github.com/gullerya/object-observer/issues/46) - added support to the `TypedArray` objects 189 | - [Issue no. 44](https://github.com/gullerya/object-observer/issues/44) - added support to the `copyWithin` method (`Array`, `TypedArray`) 190 | ### Fixed 191 | - slight performance improvements 192 | 193 | ## [2.9.4] - 2020-03-14 194 | ### Added 195 | - [Issue no. 31](https://github.com/gullerya/object-observer/issues/31) - added option to observe `pathsOf`, direct properties of a specific path only 196 | 197 | ## [2.8.0] - 2020-03-13 198 | ### Added 199 | - officially publishing and documenting [Issue no. 33](https://github.com/gullerya/object-observer/issues/33) - any nested object of an `Observable` graph is observable in itself 200 | 201 | ## [2.7.0] - 2020-02-27 202 | ### Added 203 | - [Issue no. 29](https://github.com/gullerya/object-observer/issues/32) - added experimental functionality of nested objects being observables on their own (not yet documented) 204 | - [Issue no. 29](https://github.com/gullerya/object-observer/issues/33) - added experimental functionality of nested objects being observables on their own (not yet documented) 205 | 206 | ## [2.6.0] - 2020-02-24 207 | ### Added 208 | - [Issue no. 29](https://github.com/gullerya/object-observer/issues/29) - added experimental functionality of nested objects being observables on their own (not yet documented) 209 | ### Changed 210 | - updated performance numbers: slightly affected by the new functionality, Edge became obsolete while Chromium-Edge entered the picture, measured NodeJS 211 | 212 | ## [2.5.0] - 2019-11-07 213 | ### Fixed 214 | - [Issue no. 28](https://github.com/gullerya/object-observer/issues/28) - fixing non-observable objects detection 215 | 216 | ## [2.4.2] - 2019-10-10 217 | ### Fixed 218 | - minor improvenent in the CI part of the library due to newer/better version of the test runner 219 | -------------------------------------------------------------------------------- /docs/performance-report.md: -------------------------------------------------------------------------------- 1 | # Performance Report 2 | 3 | ### General 4 | `object-observer` is purposed to be a low-level library. 5 | It is designed to track and deliver changes in a __synchronous__ way, being __async__ possible as opt in. 6 | As a such, I've put some effort to optimize it to have the least possible footprint on the consuming application. 7 | 8 | Generally speaking, the framework implies some overhead on the following, when operating on __observed__ data sets: 9 | - mutations of an observed objects: proxying the changes, detecting if there are any interested observers/listeners, building and delivering the changes 10 | - reading from observed arrays: detection of read property is performed in order to supply array mutation methods like `shift`, `push`, `splice`, `reverse` etc 11 | - mutation of __values__ that are objects / arrays: additional overhead comes from attaching / detaching those to the observed graph, proxying newcomers, revoking removed ones, creating internal system observers 12 | 13 | Pay attention: __each and every__ object / array (including all the nested ones) added to the observed tree processed by means of cloning and turning into observed one; in the same way, __each and every__ object / array removed from the observed tree is being 'restored' (proxy revoked and cloned object returned, but not to the actual original object). 14 | 15 | Tests described below are covering most of those flows. 16 | 17 | __Overall, `object-observer`'s impact on the application is negligible from both, CPU and memory aspects.__ 18 | 19 | 20 | ### Hardware 21 | All of the benchmarks below were performed on __MacBook Pro__ (model 2019, Ventura 13.2.1), plugged in at the moment of tests: 22 | - CPU 2.6 GHz 6-Core Intel Core i7 23 | - 16 GB 2667 MHz DDR4 24 | 25 | ### Tests 26 | 27 | ##### __CASE 1__ - creating observables, mutating nested primitive properties of an observable 28 | 29 | 1. __Creating__ in loop 100,000 observable from the object below, having few primitive properties, one non-observable nested object level 1 (Date), one nested object level 1, one nested object level 2 and one nested array level 1: 30 | ```javascript 31 | let person = { 32 | name: 'Anna Guller', 33 | accountCreated: new Date(), 34 | age: 20, 35 | address: { 36 | city: 'Dreamland', 37 | street: { 38 | name: 'Hope', 39 | apt: 123 40 | } 41 | }, 42 | orders: [] 43 | }; 44 | 45 | // creation, while storing the result on the same variable 46 | for (let i = 0; i < creationIterations; i++) { 47 | observable = Observable.from(person); 48 | } 49 | ``` 50 | 51 | 2. Last observable created in previous step is used to __mutate__ nested primitive property, while 2 observers added to watch for the changes, as following: 52 | ```javascript 53 | // add listeners/callbacks 54 | Observable.observe(observable, changes => { 55 | if (!changes.length) throw new Error('expected to have at least one change in the list'); 56 | else changesCountA += changes.length; 57 | }); 58 | Observable.observe(observable, changes => { 59 | if (!changes) throw new Error('expected changes list to be defined'); 60 | else changesCountB += changes.length; 61 | }); 62 | 63 | // deep mutation performed in a loop of 1,000,000 64 | for (let i = 0; i < mutationIterations; i++) { 65 | observable.address.street.apt = i; 66 | } 67 | ``` 68 | 69 | 3. Then the same setup is used to __add__ 1,000,000 nested primitive properties, as following: 70 | ```javascript 71 | for (let i = 0; i < mutationIterations; i++) { 72 | observable.address.street[i] = i; 73 | } 74 | ``` 75 | 76 | 4. Finally, those newly added properties are also being __deleted__, as following: 77 | ```javascript 78 | for (let i = 0; i < mutationIterations; i++) { 79 | delete observable.address.street[i]; 80 | } 81 | ``` 82 | 83 | All of those mutations are being watched by the listeners mentioned above and the counters are being verified to match the expectations. 84 | 85 | Below are results of those tests, where the time shown is of a single operation in average. 86 | All times are given in 'ms', meaning that cost of a single operation on Chromiums/NodeJS is usually half to few nanoseconds. Firefox values are slightly higher (worse). 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 101 | 104 | 107 | 110 | 111 | 112 | 113 | 116 | 119 | 122 | 125 | 126 | 127 | 128 | 131 | 134 | 137 | 140 | 141 | 142 | 143 | 146 | 149 | 152 | 155 | 156 |
create observable
100,000 times
mutate primitive
depth L3; 1M times
add primitive
depth L3; 1M times
delete primitive
depth L3; 1M times
98 99 | 0.001 ms 100 | 102 | 0.0004 ms 103 | 105 | 0.0006 ms 106 | 108 | 0.0005 ms 109 |
80 114 | 0.001 ms 115 | 117 | 0.0004 ms 118 | 120 | 0.0006 ms 121 | 123 | 0.0005 ms 124 |
74 129 | 0.0047 ms 130 | 132 | 0.0007 ms 133 | 135 | 0.0007 ms 136 | 138 | 0.0011 ms 139 |
18.14.2 144 | 0.0016 ms 145 | 147 | 0.001 ms 148 | 150 | 0.001 ms 151 | 153 | 0.001 ms 154 |
157 | 158 | ##### __CASE 2__ - filling an array by pushing objects, mutating nested arrays of those, popping the array back to empty 159 | 160 | 1. __Pushing__ in loop 100,000 objects as below in an array nested 1 level: 161 | ```javascript 162 | let person = { 163 | name: 'Anna Guller', 164 | accountCreated: new Date(), 165 | age: 20, 166 | address: { 167 | city: 'Dreamland', 168 | street: { 169 | name: 'Hope', 170 | apt: 123 171 | } 172 | }, 173 | orders: [] 174 | }, 175 | dataset = { 176 | users: [] 177 | }, 178 | observable = Observable.from(dataset); // the observable we'll be working with 179 | 180 | // filling the array of users 181 | for (let i = 0; i < mutationIterations; i++) { 182 | observable.users.push(person); 183 | } 184 | ``` 185 | 186 | 2. __Mutating__ nested `orders` array from an empty to the below one: 187 | ```javascript 188 | let orders = [ 189 | {id: 1, description: 'some description', sum: 1234, date: new Date()}, 190 | {id: 2, description: 'some description', sum: 1234, date: new Date()}, 191 | {id: 3, description: 'some description', sum: 1234, date: new Date()} 192 | ]; 193 | 194 | for (let i = 0; i < mutationIterations; i++) { 195 | observable.users[i].orders = orders; 196 | } 197 | ``` 198 | 199 | 3. Finally, the base `users` array is being emptied by popping it to the end: 200 | ```javascript 201 | for (let i = 0; i < mutationIterations; i++) { 202 | observable.users.pop(); 203 | } 204 | ``` 205 | 206 | All of those mutations are being watched by the same 2 listeners from CASE 1 and the counters are being verified to match the expectations. 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 220 | 223 | 226 | 227 | 228 | 229 | 232 | 235 | 238 | 239 | 240 | 241 | 244 | 247 | 250 | 251 | 252 | 253 | 256 | 259 | 262 | 263 |
push 100,000 objectsreplace nested array 100,000 timespop 100,000 objects
98 218 | 0.002 ms 219 | 221 | 0.003 ms 222 | 224 | 0.0008 ms 225 |
98 230 | 0.002 ms 231 | 233 | 0.003 ms 234 | 236 | 0.0008 ms 237 |
74 242 | 0.0077 ms 243 | 245 | 0.0096 ms 246 | 248 | 0.0011 ms 249 |
18.14.2 254 | 0.005 ms 255 | 257 | 0.005 ms 258 | 260 | 0.001 ms 261 |
264 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | [![npm](https://img.shields.io/npm/v/@gullerya/object-observer.svg?label=npm)](https://www.npmjs.com/package/@gullerya/object-observer) 2 | [![GitHub](https://img.shields.io/github/license/gullerya/object-observer.svg)](https://github.com/gullerya/object-observer) 3 | 4 | [![Quality pipeline](https://github.com/gullerya/object-observer/actions/workflows/quality.yml/badge.svg)](https://github.com/gullerya/object-observer/actions/workflows/quality.yml) 5 | [![Codecov](https://img.shields.io/codecov/c/github/gullerya/object-observer/main.svg)](https://codecov.io/gh/gullerya/object-observer/branch/main) 6 | [![Codacy](https://img.shields.io/codacy/grade/a3879d7077eb4eef83a591733ad7c579.svg?logo=codacy)](https://www.codacy.com/app/gullerya/object-observer) 7 | 8 | # `object-observer` 9 | 10 | __`object-observer`__ provides a deep observation of a changes performed on an object/array graph. 11 | 12 | Main aspects and features: 13 | - implemented via native __Proxy__ (revokable) 14 | - observation is 'deep', yielding changes from a __sub-graphs__ too 15 | - nested objects of the observable graph are observables too 16 | - changes delivered in a __synchronous__ way by default, __asynchronous__ delivery is optionally available as per `Observable` configuration; [more details here](docs/sync-async.md) 17 | - observed path may optionally be filtered as per `observer` configuration; [more details here](docs/filter-paths.md) 18 | - original objects are __cloned__ while turned into `Observable`s 19 | - circular references are nullified in the clone 20 | - __array__ specifics: 21 | - generic object-like mutations supported 22 | - intrinsic `Array` mutation methods supported: `pop`, `push`, `shift`, `unshift`, `reverse`, `sort`, `fill`, `splice`, `copyWithin` 23 | - massive mutations delivered in a single callback, usually having an array of an atomic changes 24 | - __typed array__ specifics: 25 | - generic object-like mutations supported 26 | - intrinsic `TypedArray` mutation methods supported: `reverse`, `sort`, `fill`, `set`, `copyWithin` 27 | - massive mutations delivered in a single callback, usually having an array of an atomic changes 28 | - intrinsic mutation methods of `Map`, `WeakMap`, `Set`, `WeakSet` (`set`, `delete`) etc __are not__ observed (see this [issue](https://github.com/gullerya/object-observer/issues/1) for more details) 29 | - following host objects (and their extensions) are __skipped__ from cloning / turning into observables: `Date` 30 | 31 | Supported: 32 | ![CHROME](docs/browser-icons/chrome.png)71+ | 33 | ![FIREFOX](docs/browser-icons/firefox.png)65+ | 34 | ![EDGE](docs/browser-icons/edge-chromium.png)79+ | 35 | ![SAFARI](docs/browser-icons/safari-ios.png)12.1 | 36 | ![NODE JS](docs/browser-icons/nodejs.png) 12.0.0+ 37 | 38 | Performance report can be found [here](docs/performance-report.md). 39 | 40 | Changelog is [here](docs/changelog.md). 41 | 42 | ## Preview 43 | 44 | For a preview/playground you are welcome to: 45 | - [CodePen](https://codepen.io/gullerya/pen/zYrGMNB) - `Observable.from()` flavor 46 | - [CodePen](https://codepen.io/gullerya/pen/WNRLJWY) - `new ObjectObserver()` flavor 47 | 48 | ## Install 49 | 50 | Use regular `npm install @gullerya/object-observer --save-prod` to use the library from your local environment. 51 | 52 | __ES__ module: 53 | ```js 54 | import { Observable } from '@gullerya/object-observer'; 55 | ``` 56 | 57 | __CJS__ flavor: 58 | ```js 59 | const { Observable } = require('@gullerya/object-observer'); 60 | ``` 61 | > Huge thanks to [seidelmartin](https://github.com/seidelmartin) providing the CJS build while greatly improving the build code overall along the way! 62 | 63 | __CDN__ (most suggested, when possible): 64 | ```js 65 | import { Observable } from 'https://libs.gullerya.com/object-observer/x.y.z/object-observer.min.js'; 66 | ``` 67 | 68 | > Replace the `x.y.z` with the desired version, one of the listed in the [changelog](docs/changelog.md). 69 | 70 | CDN features: 71 | - security: 72 | - __HTTPS__ only 73 | - __intergrity__ checksums for SRI 74 | - performance 75 | - highly __available__ (with many geo spread edges) 76 | - agressive __caching__ setup 77 | 78 | Full details about CDN usage and example are [found here](docs/cdn.md). 79 | 80 | ## API 81 | 82 | Library implements `Observable` API as it is defined [here](docs/observable.md). 83 | 84 | There is also a 'DOM-like' API flavor - constructable `ObjectObserver`. 85 | This API is resonating with DOM's `MutationObserver`, `ResizeObserver` etc from the syntax perspective. 86 | Under the hood it uses the same `Observable` mechanics. 87 | Read docs about this API flavor [here](docs/dom-like-api.md). 88 | 89 | `object-observer` is cross-instance operable. 90 | Observables created by different instances of the library will still be detected correctly as such and handled correctly by any of the instances. 91 | 92 | ## Security 93 | 94 | Security policy is described [here](https://github.com/gullerya/object-observer/blob/main/docs/security.md). If/when any concern raised, please follow the process. 95 | 96 | ## Examples 97 | 98 | ##### Objects 99 | 100 | ```javascript 101 | const 102 | order = { type: 'book', pid: 102, ammount: 5, remark: 'remove me' }, 103 | observableOrder = Observable.from(order); 104 | 105 | Observable.observe(observableOrder, changes => { 106 | changes.forEach(change => { 107 | console.log(change); 108 | }); 109 | }); 110 | 111 | 112 | observableOrder.ammount = 7; 113 | // { type: 'update', path: ['ammount'], value: 7, oldValue: 5, object: observableOrder } 114 | 115 | 116 | observableOrder.address = { 117 | street: 'Str 75', 118 | apt: 29 119 | }; 120 | // { type: "insert", path: ['address'], value: { ... }, object: observableOrder } 121 | 122 | 123 | observableOrder.address.apt = 30; 124 | // { type: "update", path: ['address','apt'], value: 30, oldValue: 29, object: observableOrder.address } 125 | 126 | 127 | delete observableOrder.remark; 128 | // { type: "delete", path: ['remark'], oldValue: 'remove me', object: observableOrder } 129 | 130 | Object.assign(observableOrder, { amount: 1, remark: 'less is more' }, { async: true }); 131 | // - by default the changes below would be delivered in a separate callback 132 | // - due to async use, they are delivered as a batch in a single callback 133 | // { type: 'update', path: ['ammount'], value: 1, oldValue: 7, object: observableOrder } 134 | // { type: 'insert', path: ['remark'], value: 'less is more', object: observableOrder } 135 | ``` 136 | 137 | ##### Arrays 138 | 139 | ```javascript 140 | let a = [ 1, 2, 3, 4, 5 ], 141 | observableA = Observable.from(a); 142 | 143 | Observable.observe(observableA, changes => { 144 | changes.forEach(change => { 145 | console.log(change); 146 | }); 147 | }); 148 | 149 | 150 | // observableA = [ 1, 2, 3, 4, 5 ] 151 | observableA.pop(); 152 | // { type: 'delete', path: [4], value: undefined, oldValue: 5, object: observableA } 153 | 154 | 155 | // now observableA = [ 1, 2, 3, 4 ] 156 | // following operation will cause a single callback to the observer with an array of 2 changes in it) 157 | observableA.push('a', 'b'); 158 | // { type: 'insert', path: [4], value: 'a', oldValue: undefined, object: observableA } 159 | // { type: 'insert', path: [5], value: 'b', oldValue: undefined, object: observableA } 160 | 161 | 162 | // now observableA = [1, 2, 3, 4, 'a', 'b'] 163 | observableA.shift(); 164 | // { type: 'delete', path: [0] value: undefined, oldValue: 1, object: observableA } 165 | 166 | 167 | // now observableA = [ 2, 3, 4, 'a', 'b' ] 168 | // following operation will cause a single callback to the observer with an array of 2 changes in it) 169 | observableA.unshift('x', 'y'); 170 | // { type: 'insert', path: [0], value: 'x', oldValue: undefined, object: observableA } 171 | // { type: 'insert', path: [1], value: 'y', oldValue: undefined, object: observableA } 172 | 173 | 174 | // now observableA = [ 2, 3, 4, 'a', 'b' ] 175 | observableA.reverse(); 176 | // { type: 'reverse', path: [], object: observableA } (see below and exampe of this event for nested array) 177 | 178 | 179 | // now observableA = [ 'b', 'a', 4, 3, 2 ] 180 | observableA.sort(); 181 | // { type: 'shuffle', path: [], object: observableA } (see below and exampe of this event for nested array) 182 | 183 | 184 | // observableA = [ 2, 3, 4, 'a', 'b' ] 185 | observableA.fill(0, 0, 1); 186 | // { type: 'update', path: [0], value: 0, oldValue: 2, object: observableA } 187 | 188 | 189 | // observableA = [ 0, 3, 4, 'a', 'b' ] 190 | // the following operation will cause a single callback to the observer with an array of 2 changes in it) 191 | observableA.splice(0, 1, 'x', 'y'); 192 | // { type: 'update', path: [0], value: 'x', oldValue: 0, object: observableA } 193 | // { type: 'insert', path: [1], value: 'y', oldValue: undefined, object: observableA } 194 | 195 | 196 | let customer = { orders: [ ... ] }, 197 | oCustomer = Observable.from(customer); 198 | 199 | // sorting the orders array, pay attention to the path in the event 200 | oCustomer.orders.sort(); 201 | // { type: 'shuffle', path: ['orders'], object: oCustomer.orders } 202 | 203 | 204 | oCustomer.orders.reverse(); 205 | // { type: 'reverse', path: ['orders'], object: oCustomer.orders } 206 | ``` 207 | 208 | > Arrays notes: Some of array operations are effectively moving/reindexing the whole array (shift, unshift, splice, reverse, sort). 209 | In cases of massive changes touching presumably the whole array I took a pessimistic approach with a special non-detailed events: 'reverse' for `reverse`, 'shuffle' for `sort`. The rest of these methods I'm handling in an optimistic way delivering the changes that are directly related to the method invocation, while leaving out the implicit outcomes like reindexing of the rest of the Array. 210 | 211 | ##### Observation options 212 | 213 | `object-observer` allows to filter the events delivered to each callback/listener by an optional configuration object passed to the `observe` API. 214 | 215 | > In the examples below assume that `callback = changes => {...}`. 216 | 217 | ```javascript 218 | let user = { 219 | firstName: 'Aya', 220 | lastName: 'Guller', 221 | address: { 222 | city: 'of mountaineers', 223 | street: 'of the top ridges', 224 | block: 123, 225 | extra: { 226 | data: {} 227 | } 228 | } 229 | }, 230 | oUser = Observable.from(user); 231 | 232 | // path 233 | // 234 | // going to observe ONLY the changes of 'firstName' 235 | Observable.observe(oUser, callback, {path: 'firstName'}); 236 | 237 | // going to observe ONLY the changes of 'address.city' 238 | Observable.observe(oUser, callback, {path: 'address.city'}); 239 | 240 | // pathsOf 241 | // 242 | // going to observe the changes of 'address' own properties ('city', 'block') but not else 243 | Observable.observe(oUser, callback, {pathsOf: 'address'}); 244 | // here we'll be notified on changes of 245 | // address.city 246 | // address.extra 247 | 248 | // pathsFrom 249 | // 250 | // going to observe the changes from 'address' and deeper 251 | Observable.observe(oUser, callback, {pathsFrom: 'address'}); 252 | // here we'll be notified on changes of 253 | // address 254 | // address.city 255 | // address.extra 256 | // address.extra.data 257 | ``` 258 | -------------------------------------------------------------------------------- /docs/filter-graphs/filter-paths.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/filter-graphs/filter-paths-from.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/filter-graphs/filter-paths-of.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/object-observer-arrays.js: -------------------------------------------------------------------------------- 1 | import { test } from '@gullerya/just-test'; 2 | import { assert } from '@gullerya/just-test/assert'; 3 | import { Observable } from '../src/object-observer.js'; 4 | 5 | test('array push - primitives', () => { 6 | const 7 | pa = Observable.from([1, 2, 3, 4]), 8 | events = []; 9 | let callBacks = 0; 10 | 11 | Observable.observe(pa, eventsList => { 12 | [].push.apply(events, eventsList); 13 | callBacks++; 14 | }); 15 | 16 | pa.push(5); 17 | pa.push(6, 7); 18 | 19 | assert.strictEqual(events.length, 3); 20 | assert.strictEqual(callBacks, 2); 21 | assert.deepStrictEqual(events[0], { type: 'insert', path: [4], value: 5, oldValue: undefined, object: pa }); 22 | assert.deepStrictEqual(events[1], { type: 'insert', path: [5], value: 6, oldValue: undefined, object: pa }); 23 | assert.deepStrictEqual(events[2], { type: 'insert', path: [6], value: 7, oldValue: undefined, object: pa }); 24 | }); 25 | 26 | test('array push - objects', () => { 27 | const 28 | pa = Observable.from([]), 29 | events = []; 30 | 31 | Observable.observe(pa, eventsList => { 32 | [].push.apply(events, eventsList); 33 | }); 34 | 35 | pa.push({ text: 'initial' }, { text: 'secondary' }); 36 | assert.strictEqual(events.length, 2); 37 | assert.deepStrictEqual(events[0], { type: 'insert', path: [0], value: { text: 'initial' }, oldValue: undefined, object: pa }); 38 | assert.deepStrictEqual(events[1], { type: 'insert', path: [1], value: { text: 'secondary' }, oldValue: undefined, object: pa }); 39 | 40 | pa[0].text = 'name'; 41 | assert.strictEqual(events.length, 3); 42 | assert.deepStrictEqual(events[2], { type: 'update', path: [0, 'text'], value: 'name', oldValue: 'initial', object: pa[0] }); 43 | 44 | pa[1].text = 'more'; 45 | assert.strictEqual(events.length, 4); 46 | assert.deepStrictEqual(events[3], { type: 'update', path: [1, 'text'], value: 'more', oldValue: 'secondary', object: pa[1] }); 47 | }); 48 | 49 | test('array push - arrays', () => { 50 | const 51 | pa = Observable.from([]), 52 | events = []; 53 | 54 | Observable.observe(pa, eventsList => { 55 | [].push.apply(events, eventsList); 56 | }); 57 | 58 | pa.push([], [{}]); 59 | assert.strictEqual(events.length, 2); 60 | assert.deepStrictEqual(events[0], { type: 'insert', path: [0], value: [], oldValue: undefined, object: pa }); 61 | assert.deepStrictEqual(events[1], { type: 'insert', path: [1], value: [{}], oldValue: undefined, object: pa }); 62 | 63 | pa[0].push('name'); 64 | assert.strictEqual(events.length, 3); 65 | assert.deepStrictEqual(events[2], { type: 'insert', path: [0, 0], value: 'name', oldValue: undefined, object: pa[0] }); 66 | 67 | pa[1][0].prop = 'more'; 68 | assert.strictEqual(events.length, 4); 69 | assert.deepStrictEqual(events[3], { type: 'insert', path: [1, 0, 'prop'], value: 'more', oldValue: undefined, object: pa[1][0] }); 70 | }); 71 | 72 | test('array pop - primitives', () => { 73 | const 74 | pa = Observable.from(['some']), 75 | events = []; 76 | 77 | Observable.observe(pa, eventsList => { 78 | [].push.apply(events, eventsList); 79 | }); 80 | 81 | const popped = pa.pop(); 82 | 83 | assert.strictEqual(events.length, 1); 84 | assert.deepStrictEqual(events[0], { type: 'delete', path: [0], value: undefined, oldValue: 'some', object: pa }); 85 | assert.strictEqual(popped, 'some'); 86 | }); 87 | 88 | test('array pop - objects', () => { 89 | const 90 | pa = Observable.from([{ test: 'text' }]), 91 | pad = pa[0], 92 | events = [], 93 | eventsA = []; 94 | 95 | Observable.observe(pa, eventsList => Array.prototype.push.apply(events, eventsList)); 96 | 97 | pa[0].test = 'test'; 98 | pad.test = 'more'; 99 | assert.strictEqual(events.length, 2); 100 | 101 | const popped = pa.pop(); 102 | assert.strictEqual(popped.test, 'more'); 103 | assert.strictEqual(events.length, 3); 104 | 105 | popped.new = 'value'; 106 | assert.strictEqual(events.length, 3); 107 | 108 | Observable.observe(pad, changes => Array.prototype.push.apply(eventsA, changes)); 109 | pad.test = 'change'; 110 | assert.strictEqual(eventsA.length, 1); 111 | }); 112 | 113 | test('array unshift - primitives', () => { 114 | const 115 | pa = Observable.from([]), 116 | events = []; 117 | let callbacks = 0; 118 | 119 | Observable.observe(pa, eventsList => { 120 | [].push.apply(events, eventsList); 121 | callbacks++; 122 | }); 123 | 124 | pa.unshift('a'); 125 | pa.unshift('b', 'c'); 126 | assert.strictEqual(events.length, 3); 127 | assert.strictEqual(callbacks, 2); 128 | assert.deepStrictEqual(events[0], { type: 'insert', path: [0], value: 'a', oldValue: undefined, object: pa }); 129 | assert.deepStrictEqual(events[1], { type: 'insert', path: [0], value: 'b', oldValue: undefined, object: pa }); 130 | assert.deepStrictEqual(events[2], { type: 'insert', path: [1], value: 'c', oldValue: undefined, object: pa }); 131 | }); 132 | 133 | test('array unshift - objects', () => { 134 | const 135 | pa = Observable.from([{ text: 'original' }]), 136 | events = []; 137 | 138 | Observable.observe(pa, eventsList => { 139 | [].push.apply(events, eventsList); 140 | }); 141 | 142 | pa.unshift({ text: 'initial' }); 143 | assert.strictEqual(events.length, 1); 144 | assert.deepStrictEqual(events[0], { type: 'insert', path: [0], value: { text: 'initial' }, oldValue: undefined, object: pa }); 145 | events.splice(0); 146 | 147 | pa[0].text = 'name'; 148 | pa[1].text = 'other'; 149 | assert.strictEqual(events.length, 2); 150 | assert.deepStrictEqual(events[0], { type: 'update', path: [0, 'text'], value: 'name', oldValue: 'initial', object: pa[0] }); 151 | assert.deepStrictEqual(events[1], { type: 'update', path: [1, 'text'], value: 'other', oldValue: 'original', object: pa[1] }); 152 | }); 153 | 154 | test('array unshift - arrays', () => { 155 | const 156 | pa = Observable.from([{ text: 'original' }]), 157 | events = []; 158 | 159 | Observable.observe(pa, eventsList => { 160 | [].push.apply(events, eventsList); 161 | }); 162 | 163 | pa.unshift([{}]); 164 | assert.strictEqual(events.length, 1); 165 | assert.deepStrictEqual(events[0], { type: 'insert', path: [0], value: [{}], oldValue: undefined, object: pa }); 166 | events.splice(0); 167 | 168 | pa[0][0].text = 'name'; 169 | pa[1].text = 'other'; 170 | assert.strictEqual(events.length, 2); 171 | assert.deepStrictEqual(events[0], { type: 'insert', path: [0, 0, 'text'], value: 'name', oldValue: undefined, object: pa[0][0] }); 172 | assert.deepStrictEqual(events[1], { type: 'update', path: [1, 'text'], value: 'other', oldValue: 'original', object: pa[1] }); 173 | }); 174 | 175 | test('array shift - primitives', () => { 176 | const 177 | pa = Observable.from(['some']), 178 | events = []; 179 | 180 | Observable.observe(pa, eventsList => Array.prototype.push.apply(events, eventsList)); 181 | 182 | const shifted = pa.shift(); 183 | 184 | assert.strictEqual(events.length, 1); 185 | assert.deepStrictEqual(events[0], { type: 'delete', path: [0], value: undefined, oldValue: 'some', object: pa }); 186 | assert.strictEqual(shifted, 'some'); 187 | }); 188 | 189 | test('array shift - objects', () => { 190 | const 191 | pa = Observable.from([{ text: 'a', inner: { test: 'more' } }, { text: 'b' }]), 192 | pa0 = pa[0], 193 | pa0i = pa0.inner, 194 | events = [], 195 | eventsA = []; 196 | 197 | Observable.observe(pa, eventsList => Array.prototype.push.apply(events, eventsList)); 198 | 199 | pa[0].text = 'b'; 200 | pa0i.test = 'test'; 201 | assert.strictEqual(events.length, 2); 202 | events.splice(0); 203 | 204 | const shifted = pa.shift(); 205 | assert.deepStrictEqual(shifted, { text: 'b', inner: { test: 'test' } }); 206 | 207 | assert.strictEqual(events.length, 1); 208 | assert.deepStrictEqual(events[0], { type: 'delete', path: [0], value: undefined, oldValue: { text: 'b', inner: { test: 'test' } }, object: pa }); 209 | events.splice(0); 210 | 211 | pa[0].text = 'c'; 212 | assert.strictEqual(events.length, 1); 213 | assert.deepStrictEqual(events[0], { type: 'update', path: [0, 'text'], value: 'c', oldValue: 'b', object: pa[0] }); 214 | events.splice(0); 215 | 216 | shifted.text = 'd'; 217 | assert.strictEqual(events.length, 0); 218 | 219 | Observable.observe(pa0i, changes => Array.prototype.push.apply(eventsA, changes)); 220 | pa0i.test = 'dk'; 221 | assert.strictEqual(eventsA.length, 1); 222 | }); 223 | 224 | test('array reverse - primitives (flat array)', () => { 225 | const 226 | pa = Observable.from([1, 2, 3]), 227 | events = []; 228 | 229 | Observable.observe(pa, eventsList => { 230 | [].push.apply(events, eventsList); 231 | }); 232 | 233 | const reversed = pa.reverse(); 234 | 235 | assert.strictEqual(reversed, pa); 236 | assert.strictEqual(events.length, 1); 237 | assert.deepStrictEqual(events[0], { type: 'reverse', path: [], value: undefined, oldValue: undefined, object: pa }); 238 | assert.deepStrictEqual(pa, [3, 2, 1]); 239 | }); 240 | 241 | test('array reverse - primitives (nested array)', () => { 242 | const 243 | pa = Observable.from({ a1: { a2: [1, 2, 3] } }), 244 | events = []; 245 | 246 | Observable.observe(pa, eventsList => { 247 | [].push.apply(events, eventsList); 248 | }); 249 | 250 | const reversed = pa.a1.a2.reverse(); 251 | 252 | assert.strictEqual(reversed, pa.a1.a2); 253 | assert.strictEqual(events.length, 1); 254 | assert.deepStrictEqual(events[0], { type: 'reverse', path: ['a1', 'a2'], value: undefined, oldValue: undefined, object: pa.a1.a2 }); 255 | assert.deepStrictEqual(pa.a1.a2, [3, 2, 1]); 256 | }); 257 | 258 | test('array reverse - objects', () => { 259 | const 260 | pa = Observable.from([{ name: 'a' }, { name: 'b' }, { name: 'c' }]), 261 | events = []; 262 | 263 | Observable.observe(pa, eventsList => { 264 | [].push.apply(events, eventsList); 265 | }); 266 | 267 | pa[0].name = 'A'; 268 | const reversed = pa.reverse(); 269 | pa[0].name = 'C'; 270 | 271 | assert.strictEqual(reversed, pa); 272 | assert.strictEqual(events.length, 3); 273 | assert.deepStrictEqual(events[0], { type: 'update', path: [0, 'name'], value: 'A', oldValue: 'a', object: pa[2] }); 274 | assert.deepStrictEqual(events[1], { type: 'reverse', path: [], value: undefined, oldValue: undefined, object: pa }); 275 | assert.deepStrictEqual(events[2], { type: 'update', path: [0, 'name'], value: 'C', oldValue: 'c', object: pa[0] }); 276 | }); 277 | 278 | test('array sort - primitives (flat array)', () => { 279 | const 280 | pa = Observable.from([3, 2, 1]), 281 | events = []; 282 | 283 | Observable.observe(pa, eventsList => { 284 | [].push.apply(events, eventsList); 285 | }); 286 | 287 | let sorted = pa.sort(); 288 | 289 | assert.strictEqual(sorted, pa); 290 | assert.strictEqual(events.length, 1); 291 | assert.deepStrictEqual(events[0], { type: 'shuffle', path: [], value: undefined, oldValue: undefined, object: pa }); 292 | assert.deepStrictEqual(pa, [1, 2, 3]); 293 | 294 | sorted = pa.sort((a, b) => { 295 | return a < b ? 1 : -1; 296 | }); 297 | assert.strictEqual(sorted, pa); 298 | assert.strictEqual(events.length, 2); 299 | assert.deepStrictEqual(events[1], { type: 'shuffle', path: [], value: undefined, oldValue: undefined, object: pa }); 300 | assert.deepStrictEqual(pa, [3, 2, 1]); 301 | }); 302 | 303 | test('array sort - primitives (nested array)', () => { 304 | const 305 | pa = Observable.from({ a1: { a2: [3, 2, 1] } }), 306 | events = []; 307 | 308 | Observable.observe(pa, eventsList => { 309 | [].push.apply(events, eventsList); 310 | }); 311 | 312 | let sorted = pa.a1.a2.sort(); 313 | 314 | assert.strictEqual(sorted, pa.a1.a2); 315 | assert.strictEqual(events.length, 1); 316 | assert.deepStrictEqual(events[0], { type: 'shuffle', path: ['a1', 'a2'], value: undefined, oldValue: undefined, object: pa.a1.a2 }); 317 | assert.deepStrictEqual(pa.a1.a2, [1, 2, 3]); 318 | 319 | sorted = pa.a1.a2.sort((a, b) => { 320 | return a < b ? 1 : -1; 321 | }); 322 | assert.strictEqual(sorted, pa.a1.a2); 323 | assert.strictEqual(events.length, 2); 324 | assert.deepStrictEqual(events[1], { type: 'shuffle', path: ['a1', 'a2'], value: undefined, oldValue: undefined, object: pa.a1.a2 }); 325 | assert.deepStrictEqual(pa.a1.a2, [3, 2, 1]); 326 | }); 327 | 328 | test('array sort - objects', () => { 329 | const 330 | pa = Observable.from([{ name: 'a' }, { name: 'b' }, { name: 'c' }]), 331 | events = []; 332 | 333 | Observable.observe(pa, eventsList => { 334 | [].push.apply(events, eventsList); 335 | }); 336 | 337 | pa[0].name = 'A'; 338 | const sorted = pa.sort((a, b) => { 339 | return a.name < b.name ? 1 : -1; 340 | }); 341 | pa[0].name = 'C'; 342 | 343 | assert.strictEqual(sorted, pa); 344 | assert.strictEqual(events.length, 3); 345 | assert.deepStrictEqual(events[0], { type: 'update', path: [0, 'name'], value: 'A', oldValue: 'a', object: pa[2] }); 346 | assert.deepStrictEqual(events[1], { type: 'shuffle', path: [], value: undefined, oldValue: undefined, object: pa }); 347 | assert.deepStrictEqual(events[2], { type: 'update', path: [0, 'name'], value: 'C', oldValue: 'c', object: pa[0] }); 348 | }); 349 | 350 | test('array fill - primitives', () => { 351 | const 352 | pa = Observable.from([1, 2, 3]), 353 | events = []; 354 | 355 | Observable.observe(pa, eventsList => { 356 | [].push.apply(events, eventsList); 357 | }); 358 | 359 | const filled = pa.fill('a'); 360 | assert.strictEqual(filled, pa); 361 | assert.strictEqual(events.length, 3); 362 | assert.deepStrictEqual(events[0], { type: 'update', path: [0], value: 'a', oldValue: 1, object: pa }); 363 | assert.deepStrictEqual(events[1], { type: 'update', path: [1], value: 'a', oldValue: 2, object: pa }); 364 | assert.deepStrictEqual(events[2], { type: 'update', path: [2], value: 'a', oldValue: 3, object: pa }); 365 | events.splice(0); 366 | 367 | pa.fill('b', 1, 3); 368 | assert.strictEqual(events.length, 2); 369 | assert.deepStrictEqual(events[0], { type: 'update', path: [1], value: 'b', oldValue: 'a', object: pa }); 370 | assert.deepStrictEqual(events[1], { type: 'update', path: [2], value: 'b', oldValue: 'a', object: pa }); 371 | events.splice(0); 372 | 373 | pa.fill('c', -1, 3); 374 | assert.strictEqual(events.length, 1); 375 | assert.deepStrictEqual(events[0], { type: 'update', path: [2], value: 'c', oldValue: 'b', object: pa }); 376 | events.splice(0); 377 | 378 | // simulating insertion of a new item into array (fill does not extend an array, so we may do it only on internal items) 379 | delete pa[1]; 380 | pa.fill('d', 1, 2); 381 | assert.strictEqual(events.length, 2); 382 | assert.deepStrictEqual(events[0], { type: 'delete', path: ['1'], value: undefined, oldValue: 'b', object: pa }); 383 | assert.deepStrictEqual(events[1], { type: 'insert', path: [1], value: 'd', oldValue: undefined, object: pa }); 384 | }); 385 | 386 | test('array fill - objects', () => { 387 | const 388 | pa = Observable.from([{ some: 'text' }, { some: 'else' }, { some: 'more' }]), 389 | events = []; 390 | 391 | Observable.observe(pa, eventsList => { 392 | [].push.apply(events, eventsList); 393 | }); 394 | 395 | const filled = pa.fill({ name: 'Niv' }); 396 | assert.strictEqual(filled, pa); 397 | assert.strictEqual(events.length, 3); 398 | assert.deepStrictEqual(events[0], { type: 'update', path: [0], value: { name: 'Niv' }, oldValue: { some: 'text' }, object: pa }); 399 | assert.deepStrictEqual(events[1], { type: 'update', path: [1], value: { name: 'Niv' }, oldValue: { some: 'else' }, object: pa }); 400 | assert.deepStrictEqual(events[2], { type: 'update', path: [2], value: { name: 'Niv' }, oldValue: { some: 'more' }, object: pa }); 401 | events.splice(0); 402 | 403 | pa[1].name = 'David'; 404 | assert.strictEqual(events.length, 1); 405 | assert.deepStrictEqual(events[0], { type: 'update', path: [1, 'name'], value: 'David', oldValue: 'Niv', object: pa[1] }); 406 | }); 407 | 408 | test('array fill - arrays', () => { 409 | const 410 | pa = Observable.from([{ some: 'text' }, { some: 'else' }, { some: 'more' }]), 411 | events = []; 412 | 413 | Observable.observe(pa, eventsList => { 414 | [].push.apply(events, eventsList); 415 | }); 416 | 417 | const filled = pa.fill([{ name: 'Niv' }]); 418 | assert.strictEqual(filled, pa); 419 | assert.strictEqual(events.length, 3); 420 | assert.deepStrictEqual(events[0], { type: 'update', path: [0], value: [{ name: 'Niv' }], oldValue: { some: 'text' }, object: pa }); 421 | assert.deepStrictEqual(events[1], { type: 'update', path: [1], value: [{ name: 'Niv' }], oldValue: { some: 'else' }, object: pa }); 422 | assert.deepStrictEqual(events[2], { type: 'update', path: [2], value: [{ name: 'Niv' }], oldValue: { some: 'more' }, object: pa }); 423 | events.splice(0); 424 | 425 | pa[1][0].name = 'David'; 426 | assert.strictEqual(events.length, 1); 427 | assert.deepStrictEqual(events[0], { type: 'update', path: [1, 0, 'name'], value: 'David', oldValue: 'Niv', object: pa[1][0] }); 428 | }); 429 | 430 | test('array splice - primitives', () => { 431 | const 432 | pa = Observable.from([1, 2, 3, 4, 5, 6]), 433 | events = []; 434 | let callbacks = 0; 435 | 436 | Observable.observe(pa, eventsList => { 437 | [].push.apply(events, eventsList); 438 | callbacks++; 439 | }); 440 | 441 | const spliced = pa.splice(2, 2, 'a'); 442 | assert.isTrue(Array.isArray(spliced)); 443 | assert.strictEqual(spliced.length, 2); 444 | assert.strictEqual(spliced[0], 3); 445 | assert.strictEqual(spliced[1], 4); 446 | assert.strictEqual(events.length, 2); 447 | assert.strictEqual(callbacks, 1); 448 | assert.deepStrictEqual(events[0], { type: 'update', path: [2], value: 'a', oldValue: 3, object: pa }); 449 | assert.deepStrictEqual(events[1], { type: 'delete', path: [3], value: undefined, oldValue: 4, object: pa }); 450 | events.splice(0); 451 | callbacks = 0; 452 | 453 | // pa = [1,2,'a',5,6] 454 | pa.splice(-3); 455 | assert.strictEqual(events.length, 3); 456 | assert.strictEqual(callbacks, 1); 457 | assert.deepStrictEqual(events[0], { type: 'delete', path: [2], value: undefined, oldValue: 'a', object: pa }); 458 | assert.deepStrictEqual(events[1], { type: 'delete', path: [3], value: undefined, oldValue: 5, object: pa }); 459 | assert.deepStrictEqual(events[2], { type: 'delete', path: [4], value: undefined, oldValue: 6, object: pa }); 460 | events.splice(0); 461 | callbacks = 0; 462 | 463 | // pa = [1,2] 464 | pa.splice(0); 465 | assert.strictEqual(events.length, 2); 466 | assert.strictEqual(callbacks, 1); 467 | assert.deepStrictEqual(events[0], { type: 'delete', path: [0], value: undefined, oldValue: 1, object: pa }); 468 | assert.deepStrictEqual(events[1], { type: 'delete', path: [1], value: undefined, oldValue: 2, object: pa }); 469 | events.splice(0); 470 | callbacks = 0; 471 | }); 472 | 473 | test('array splice - objects', () => { 474 | const 475 | pa = Observable.from([{ text: 'a' }, { text: 'b' }, { text: 'c' }, { text: 'd' }]), 476 | events = []; 477 | 478 | Observable.observe(pa, eventsList => { 479 | [].push.apply(events, eventsList); 480 | }); 481 | 482 | pa.splice(1, 2, { text: '1' }); 483 | assert.strictEqual(events.length, 2); 484 | assert.deepStrictEqual(events[0], { type: 'update', path: [1], value: { text: '1' }, oldValue: { text: 'b' }, object: pa }); 485 | assert.deepStrictEqual(events[1], { type: 'delete', path: [2], value: undefined, oldValue: { text: 'c' }, object: pa }); 486 | events.splice(0); 487 | 488 | pa[1].text = 'B'; 489 | pa[2].text = 'D'; 490 | assert.strictEqual(events.length, 2); 491 | assert.deepStrictEqual(events[0], { type: 'update', path: [1, 'text'], value: 'B', oldValue: '1', object: pa[1] }); 492 | assert.deepStrictEqual(events[1], { type: 'update', path: [2, 'text'], value: 'D', oldValue: 'd', object: pa[2] }); 493 | events.splice(0); 494 | 495 | pa.splice(1, 1, { text: 'A' }, { text: 'B' }); 496 | assert.strictEqual(events.length, 2); 497 | assert.deepStrictEqual(events[0], { type: 'update', path: [1], value: { text: 'A' }, oldValue: { text: 'B' }, object: pa }); 498 | assert.deepStrictEqual(events[1], { type: 'insert', path: [2], value: { text: 'B' }, oldValue: undefined, object: pa }); 499 | events.splice(0); 500 | 501 | pa[3].text = 'C'; 502 | assert.strictEqual(events.length, 1); 503 | assert.deepStrictEqual(events[0], { type: 'update', path: [3, 'text'], value: 'C', oldValue: 'D', object: pa[3] }); 504 | }); 505 | 506 | test('array splice - arrays', () => { 507 | const 508 | pa = Observable.from([{ text: 'a' }, { text: 'b' }, { text: 'c' }, { text: 'd' }]), 509 | events = []; 510 | 511 | Observable.observe(pa, eventsList => { 512 | [].push.apply(events, eventsList); 513 | }); 514 | 515 | pa.splice(1, 2, [{ text: '1' }]); 516 | assert.strictEqual(events.length, 2); 517 | assert.deepStrictEqual(events[0], { type: 'update', path: [1], value: [{ text: '1' }], oldValue: { text: 'b' }, object: pa }); 518 | assert.deepStrictEqual(events[1], { type: 'delete', path: [2], value: undefined, oldValue: { text: 'c' }, object: pa }); 519 | events.splice(0); 520 | 521 | pa[1][0].text = 'B'; 522 | pa[2].text = 'D'; 523 | assert.strictEqual(events.length, 2); 524 | assert.deepStrictEqual(events[0], { type: 'update', path: [1, 0, 'text'], value: 'B', oldValue: '1', object: pa[1][0] }); 525 | assert.deepStrictEqual(events[1], { type: 'update', path: [2, 'text'], value: 'D', oldValue: 'd', object: pa[2] }); 526 | events.splice(0); 527 | 528 | const spliced = pa.splice(1, 1, { text: 'A' }, [{ text: 'B' }]); 529 | assert.strictEqual(events.length, 2); 530 | assert.strictEqual(spliced.length, 1); 531 | assert.deepStrictEqual(spliced[0], [{ text: 'B' }]); 532 | 533 | assert.deepStrictEqual(events[0], { type: 'update', path: [1], value: { text: 'A' }, oldValue: [{ text: 'B' }], object: pa }); 534 | assert.deepStrictEqual(events[1], { type: 'insert', path: [2], value: [{ text: 'B' }], oldValue: undefined, object: pa }); 535 | events.splice(0); 536 | 537 | pa[3].text = 'C'; 538 | assert.strictEqual(events.length, 1); 539 | assert.deepStrictEqual(events[0], { type: 'update', path: [3, 'text'], value: 'C', oldValue: 'D', object: pa[3] }); 540 | }); --------------------------------------------------------------------------------