├── .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 |
15 |
16 |
17 |
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 |
27 |
28 |
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 |
37 |
38 |
39 |
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 |
50 |
51 |
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 |
60 |
61 |
62 |
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 |
72 |
73 |
--------------------------------------------------------------------------------
/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 | create observable 100,000 times
92 | mutate primitive depth L3; 1M times
93 | add primitive depth L3; 1M times
94 | delete primitive depth L3; 1M times
95 |
96 |
97 | 98
98 |
99 | 0.001 ms
100 |
101 |
102 | 0.0004 ms
103 |
104 |
105 | 0.0006 ms
106 |
107 |
108 | 0.0005 ms
109 |
110 |
111 |
112 | 80
113 |
114 | 0.001 ms
115 |
116 |
117 | 0.0004 ms
118 |
119 |
120 | 0.0006 ms
121 |
122 |
123 | 0.0005 ms
124 |
125 |
126 |
127 | 74
128 |
129 | 0.0047 ms
130 |
131 |
132 | 0.0007 ms
133 |
134 |
135 | 0.0007 ms
136 |
137 |
138 | 0.0011 ms
139 |
140 |
141 |
142 | 18.14.2
143 |
144 | 0.0016 ms
145 |
146 |
147 | 0.001 ms
148 |
149 |
150 | 0.001 ms
151 |
152 |
153 | 0.001 ms
154 |
155 |
156 |
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 | push 100,000 objects
212 | replace nested array 100,000 times
213 | pop 100,000 objects
214 |
215 |
216 | 98
217 |
218 | 0.002 ms
219 |
220 |
221 | 0.003 ms
222 |
223 |
224 | 0.0008 ms
225 |
226 |
227 |
228 | 98
229 |
230 | 0.002 ms
231 |
232 |
233 | 0.003 ms
234 |
235 |
236 | 0.0008 ms
237 |
238 |
239 |
240 | 74
241 |
242 | 0.0077 ms
243 |
244 |
245 | 0.0096 ms
246 |
247 |
248 | 0.0011 ms
249 |
250 |
251 |
252 | 18.14.2
253 |
254 | 0.005 ms
255 |
256 |
257 | 0.005 ms
258 |
259 |
260 | 0.001 ms
261 |
262 |
263 |
264 |
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 | [](https://www.npmjs.com/package/@gullerya/object-observer)
2 | [](https://github.com/gullerya/object-observer)
3 |
4 | [](https://github.com/gullerya/object-observer/actions/workflows/quality.yml)
5 | [](https://codecov.io/gh/gullerya/object-observer/branch/main)
6 | [](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 | 71+ |
33 | 65+ |
34 | 79+ |
35 | 12.1 |
36 |  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 | });
--------------------------------------------------------------------------------