├── .eslintignore ├── .prettierignore ├── .prettierrc.json ├── .github ├── FUNDING.yml └── workflows │ ├── test.yml │ └── docs.yml ├── .gitignore ├── test ├── snapshots │ ├── memoize-test.ts.snap │ └── memoize-test.ts.md ├── immer-test.ts ├── memoize-test.ts └── watcher-test.ts ├── src ├── reflect.ts ├── util.ts └── index.ts ├── .release-it.json ├── .eslintrc.js ├── benchmarks ├── watch-unwrap-bench.ts ├── memoize-bench.ts ├── isChanged-bench.ts ├── isNotChanged-bench.ts └── data.ts ├── LICENSE ├── package.json ├── README.md └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | docs/ 3 | .eslintrc.js 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | coverage/ 3 | docs/ 4 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } 5 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [indutny] 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | coverage/ 3 | node_modules/ 4 | npm-debug.log 5 | .eslintcache 6 | .DS_Store 7 | docs/ 8 | -------------------------------------------------------------------------------- /test/snapshots/memoize-test.ts.snap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/indutny/sneequals/HEAD/test/snapshots/memoize-test.ts.snap -------------------------------------------------------------------------------- /src/reflect.ts: -------------------------------------------------------------------------------- 1 | // Just to help the minifier. 2 | const { get, getOwnPropertyDescriptor, has, ownKeys } = Reflect; 3 | 4 | export { get, getOwnPropertyDescriptor, has, ownKeys }; 5 | -------------------------------------------------------------------------------- /.release-it.json: -------------------------------------------------------------------------------- 1 | { 2 | "git": { 3 | "commitMessage": "chore: release v${version}" 4 | }, 5 | "npm": { 6 | "publish": true 7 | }, 8 | "github": { 9 | "release": true 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'], 3 | parser: '@typescript-eslint/parser', 4 | plugins: ['@typescript-eslint'], 5 | root: true, 6 | }; 7 | -------------------------------------------------------------------------------- /benchmarks/watch-unwrap-bench.ts: -------------------------------------------------------------------------------- 1 | import { watch } from '../src'; 2 | 3 | export const name = 'watch+unwrap'; 4 | 5 | export default () => { 6 | const { proxy, watcher } = watch({ a: { b: [10, 20, 30] }, c: {} }); 7 | const derived = watcher.unwrap({ num: proxy.a.b[2], c: proxy.c }); 8 | watcher.stop(); 9 | 10 | return derived.c ? derived.num ?? 0 : 0; 11 | }; 12 | -------------------------------------------------------------------------------- /benchmarks/memoize-bench.ts: -------------------------------------------------------------------------------- 1 | import { memoize } from '../src/'; 2 | import { initial, clearedUnreadCount, formatMessage } from './data'; 3 | 4 | const fn = memoize(formatMessage); 5 | 6 | export const name = 'memoize'; 7 | 8 | let i = 0; 9 | 10 | export default () => { 11 | const state = i++ % 2 === 0 ? initial : clearedUnreadCount; 12 | return fn(state.messages['second'], state) ? 1 : 0; 13 | }; 14 | -------------------------------------------------------------------------------- /benchmarks/isChanged-bench.ts: -------------------------------------------------------------------------------- 1 | import { watch } from '../src'; 2 | import { initial, readMessage, formatMessage } from './data'; 3 | 4 | const { proxy, watcher } = watch(initial); 5 | watcher.unwrap(formatMessage(proxy.messages['second'], proxy)); 6 | watcher.stop(); 7 | 8 | export const name = 'isChanged'; 9 | 10 | export default () => { 11 | const changed = watcher.isChanged(initial, readMessage); 12 | 13 | return changed ? 1 : 0; 14 | }; 15 | -------------------------------------------------------------------------------- /benchmarks/isNotChanged-bench.ts: -------------------------------------------------------------------------------- 1 | import { watch } from '../src'; 2 | import { initial, clearedUnreadCount, formatMessage } from './data'; 3 | 4 | const { proxy, watcher } = watch(initial); 5 | watcher.unwrap(formatMessage(proxy.messages['second'], proxy)); 6 | watcher.stop(); 7 | 8 | export const name = 'isNotChanged'; 9 | 10 | export default () => { 11 | const notChanged = !watcher.isChanged(initial, clearedUnreadCount); 12 | 13 | return notChanged ? 1 : 0; 14 | }; 15 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | node-version: [lts/*, latest] 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: Use Node.js ${{ matrix.node-version }} 20 | uses: actions/setup-node@v3 21 | with: 22 | node-version: ${{ matrix.node-version }} 23 | - run: npm ci 24 | - run: npm run build 25 | - run: npm run lint 26 | - run: npm test 27 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | on: 3 | release: 4 | types: [published] 5 | permissions: 6 | contents: write 7 | jobs: 8 | build-and-deploy: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout repository 12 | uses: actions/checkout@v3 13 | - name: Set up Node 14 | uses: actions/setup-node@v3 15 | with: 16 | node-version: latest 17 | - name: Install and build 18 | run: | 19 | npm ci 20 | npm run build:docs -- --gitRevision $GITHUB_REF 21 | - name: Deploy 22 | uses: JamesIves/github-pages-deploy-action@v4 23 | with: 24 | folder: docs 25 | -------------------------------------------------------------------------------- /test/immer-test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import produce from 'immer'; 3 | 4 | import { watch } from '../src'; 5 | 6 | test('immer.js compatibility', (t) => { 7 | const initial = { 8 | a: { 9 | b: { 10 | c: [1, 2, 3], 11 | }, 12 | }, 13 | }; 14 | 15 | // Immer auto-freezes the result so we have to make sure 16 | // that we won't crash on that. 17 | const changed = produce(initial, (data) => { 18 | data.a.b.c[2] = 4; 19 | }); 20 | 21 | const { proxy, watcher } = watch(initial); 22 | t.is(watcher.unwrap(proxy.a.b.c[2]), 3); 23 | 24 | t.false(watcher.isChanged(initial, initial)); 25 | t.true(watcher.isChanged(initial, changed)); 26 | }); 27 | -------------------------------------------------------------------------------- /test/snapshots/memoize-test.ts.md: -------------------------------------------------------------------------------- 1 | # Snapshot report for `test/memoize-test.ts` 2 | 3 | The actual snapshot is saved in `memoize-test.ts.snap`. 4 | 5 | Generated by [AVA](https://avajs.dev). 6 | 7 | ## memoizing objects 8 | 9 | > first parameter paths 10 | 11 | [ 12 | '$.x', 13 | ] 14 | 15 | > second parameter paths 16 | 17 | [ 18 | '$.y', 19 | ] 20 | 21 | ## unused params 22 | 23 | > first parameter paths 24 | 25 | [ 26 | '$.x', 27 | '$.y', 28 | ] 29 | 30 | > second parameter paths 31 | 32 | [] 33 | 34 | ## nested calls 35 | 36 | > first inner parameter paths 37 | 38 | [ 39 | '$.x', 40 | ] 41 | 42 | > first outer parameter paths 43 | 44 | [ 45 | '$.x', 46 | ] 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright Fedor Indutny, 2022. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | import { ownKeys } from './reflect'; 2 | 3 | export const hasSameOwnKeys = (a: object, b: object): boolean => { 4 | const aKeys = ownKeys(a); 5 | const bKeys = ownKeys(b); 6 | 7 | if (aKeys.length !== bKeys.length) { 8 | return false; 9 | } 10 | 11 | for (let i = 0; i < aKeys.length; i++) { 12 | if (aKeys[i] !== bKeys[i]) { 13 | return false; 14 | } 15 | } 16 | 17 | return true; 18 | }; 19 | 20 | export const returnFalse = () => false; 21 | 22 | export const isObject = (value: unknown): value is object => 23 | value !== null && typeof value === 'object'; 24 | 25 | const unfreezeCache = new WeakMap(); 26 | 27 | export const maybeUnfreeze = ( 28 | value: Value, 29 | kSource: symbol, 30 | ): Value => { 31 | if (!Object.isFrozen(value)) { 32 | return value; 33 | } 34 | 35 | const cached = unfreezeCache.get(value); 36 | if (cached !== undefined) { 37 | return cached as Value; 38 | } 39 | 40 | let result: Value; 41 | if (Array.isArray(value)) { 42 | result = Array.from(value) as Value; 43 | } else { 44 | const copy = {}; 45 | const descriptors = Object.getOwnPropertyDescriptors(value); 46 | for (const descriptor of Object.values(descriptors)) { 47 | descriptor.configurable = true; 48 | } 49 | Object.defineProperties(copy, descriptors); 50 | 51 | result = copy as Value; 52 | } 53 | Object.defineProperty(result, kSource, { 54 | configurable: false, 55 | enumerable: false, 56 | writable: false, 57 | value, 58 | }); 59 | unfreezeCache.set(value, result); 60 | return result; 61 | }; 62 | -------------------------------------------------------------------------------- /benchmarks/data.ts: -------------------------------------------------------------------------------- 1 | import produce from 'immer'; 2 | 3 | type Message = { 4 | content: string; 5 | authorId: string; 6 | timestamp: number; 7 | attachments: Array<{ type: string; url: string }>; 8 | status: 'sent' | 'read'; 9 | }; 10 | 11 | type Conversation = { 12 | title: string; 13 | phoneNumber: string; 14 | unreadCount: number; 15 | }; 16 | 17 | export const initial = { 18 | conversations: { 19 | alice: { 20 | title: 'Alice', 21 | phoneNumber: '+33-3-52-18-72-64', 22 | unreadCount: 5, 23 | }, 24 | bob: { 25 | title: 'Bob', 26 | phoneNumber: '+33-3-63-10-05-55', 27 | unreadCount: 0, 28 | }, 29 | } as Record, 30 | messages: { 31 | first: { 32 | content: 'Hello', 33 | authorId: 'alice', 34 | timestamp: 1671990020697, 35 | attachments: [], 36 | status: 'read', 37 | }, 38 | second: { 39 | content: 'Hey!', 40 | authorId: 'bob', 41 | timestamp: 1671990029093, 42 | attachments: [ 43 | { 44 | type: 'image/png', 45 | url: 'https://nodejs.org/static/images/logo-hexagon-card.png', 46 | }, 47 | ], 48 | status: 'sent', 49 | }, 50 | } as Record, 51 | }; 52 | 53 | export const clearedUnreadCount = produce(initial, (state) => { 54 | if (state.conversations['alice']) { 55 | state.conversations['alice'].unreadCount = 0; 56 | } 57 | }); 58 | 59 | export const readMessage = produce(initial, (state) => { 60 | if (state.messages['second']) { 61 | state.messages['second'].status = 'read'; 62 | } 63 | }); 64 | 65 | export function formatMessage( 66 | message: Message | undefined, 67 | state: typeof initial, 68 | ) { 69 | if (!message) { 70 | return undefined; 71 | } 72 | 73 | const { content, authorId, timestamp, attachments, status } = message; 74 | 75 | // Note that we never read "unreadCount" 76 | const author = state.conversations[authorId]; 77 | return { 78 | content, 79 | author: author ? `${author.title} (${author.phoneNumber})` : '', 80 | timestamp, 81 | attachments, 82 | status, 83 | }; 84 | } 85 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@indutny/sneequals", 3 | "version": "4.0.0", 4 | "description": "Sneaky equality check between objects using proxies", 5 | "main": "dist/src/index.js", 6 | "module": "dist/esm/src/index.js", 7 | "sideEffects": false, 8 | "types": "dist/src/index.d.ts", 9 | "files": [ 10 | "dist/src", 11 | "dist/esm/src", 12 | "README.md" 13 | ], 14 | "scripts": { 15 | "watch": "npm run build:cjs -- --watch", 16 | "build": "npm run build:cjs && npm run build:esm", 17 | "build:cjs": "tsc", 18 | "build:esm": "tsc --module es2020 --declaration false --outDir dist/esm", 19 | "build:docs": "typedoc src/index.ts --includeVersion", 20 | "test": "c8 --100 ava test/*.ts", 21 | "format": "prettier --cache --write .", 22 | "lint": "npm run check:eslint && npm run check:format", 23 | "check:eslint": "eslint --cache .", 24 | "check:format": "prettier --cache --check .", 25 | "prepublishOnly": "npm run clean && npm run build && npm run lint && npm run test", 26 | "bench": "bencher dist/benchmarks/*-bench.js", 27 | "release": "release-it", 28 | "clean": "rm -rf dist" 29 | }, 30 | "keywords": [ 31 | "proxy", 32 | "equality" 33 | ], 34 | "author": "Fedor Indutny <238531+indutny@users.noreply.github.com>", 35 | "license": "MIT", 36 | "repository": { 37 | "type": "git", 38 | "url": "git+https://github.com/indutny/sneequals.git" 39 | }, 40 | "bugs": { 41 | "url": "https://github.com/indutny/sneequals/issues" 42 | }, 43 | "homepage": "https://github.com/indutny/sneequals#readme", 44 | "ava": { 45 | "extensions": [ 46 | "ts" 47 | ], 48 | "require": [ 49 | "ts-node/register" 50 | ] 51 | }, 52 | "devDependencies": { 53 | "@indutny/bencher": "^1.1.1", 54 | "@typescript-eslint/eslint-plugin": "^5.47.0", 55 | "@typescript-eslint/parser": "^5.47.0", 56 | "ava": "^5.1.0", 57 | "c8": "^7.12.0", 58 | "eslint": "^8.30.0", 59 | "immer": "^9.0.16", 60 | "prettier": "^2.8.1", 61 | "release-it": "^15.6.0", 62 | "ts-node": "^10.9.1", 63 | "typedoc": "^0.23.23", 64 | "typescript": "^4.9.4" 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @indutny/sneequals 2 | 3 | [![npm](https://img.shields.io/npm/v/@indutny/sneequals)](https://www.npmjs.com/package/@indutny/sneequals) 4 | [![size](https://img.shields.io/bundlephobia/minzip/@indutny/sneequals)](https://bundlephobia.com/result?p=@indutny/sneequals) 5 | ![CI Status](https://github.com/indutny/sneequals/actions/workflows/test.yml/badge.svg) 6 | 7 | [API docs](https://indutny.github.io/sneequals). 8 | 9 | Sneaky equals comparison between objects that checks only the properties that 10 | were touched. 11 | 12 | Heavily inspired by [proxy-compare](https://github.com/dai-shi/proxy-compare). 13 | 14 | ## Installation 15 | 16 | ```sh 17 | npm install @indutny/sneequals 18 | ``` 19 | 20 | ## Usage 21 | 22 | ```js 23 | import { watch } from '@indutny/sneequals'; 24 | 25 | const originalData = { 26 | nested: { 27 | prop: 1, 28 | }, 29 | avatar: { 30 | src: 'image.png', 31 | }, 32 | }; 33 | 34 | const { proxy, watcher } = watch(originalData); 35 | 36 | function doSomethingWithData(data) { 37 | return { 38 | prop: data.nested.prop, 39 | x: data.avatar, 40 | }; 41 | } 42 | 43 | const result = watcher.unwrap(doSomethingWithData(proxy)); 44 | 45 | // Prevent further access to proxy 46 | watcher.stop(); 47 | 48 | const sneakyEqualData = { 49 | nested: { 50 | prop: 1, 51 | other: 'ignored', 52 | }, 53 | avatar: original.avatar, 54 | }; 55 | 56 | console.log(watcher.isChanged(originalData, sneakyEqualData)); // false 57 | 58 | const sneakyDifferentData = { 59 | nested: { 60 | prop: 2, 61 | }, 62 | avatar: { 63 | ...original.avatar, 64 | }, 65 | }; 66 | 67 | console.log(watcher.isChanged(originalData, sneakyDifferentData)); // true 68 | ``` 69 | 70 | ## Benchmarks 71 | 72 | On M1 Macbook Pro 13: 73 | 74 | ```sh 75 | % npm run bench -- --duration 60 --ignore-outliers 76 | 77 | > @indutny/sneequals@1.3.5 bench 78 | > bencher dist/benchmarks/*.js 79 | 80 | isChanged: 4’347’490.6 ops/sec (±21’862.0, p=0.001, o=3/100) 81 | isNotChanged: 7’826’035.5 ops/sec (±46’826.6, p=0.001, o=0/100) 82 | memoize: 8’244’416.2 ops/sec (±34’162.8, p=0.001, o=1/100) 83 | watch+unwrap: 729’825.5 ops/sec (±1’403.9, p=0.001, o=5/100) 84 | ``` 85 | 86 | ## Credits 87 | 88 | - Based on [proxy-compare](https://github.com/dai-shi/proxy-compare) by 89 | [dai-shi](https://github.com/dai-shi) 90 | - Name coined by [Scott Nonnenberg](https://github.com/scottnonnenberg/). 91 | 92 | ## LICENSE 93 | 94 | This software is licensed under the MIT License. 95 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig to read more about this file */ 4 | 5 | /* Projects */ 6 | "incremental": true /* Save .tsbuildinfo files to allow for incremental compilation of projects. */, 7 | 8 | /* Language and Environment */ 9 | "target": "es2022" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, 10 | 11 | /* Modules */ 12 | "module": "commonjs" /* Specify what module code is generated. */, 13 | "moduleResolution": "node", 14 | 15 | /* Emit */ 16 | "declaration": true, 17 | "outDir": "./dist" /* Specify an output folder for all emitted files. */, 18 | 19 | /* Interop Constraints */ 20 | "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, 21 | "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, 22 | 23 | /* Type Checking */ 24 | "strict": true /* Enable all strict type-checking options. */, 25 | "noImplicitAny": true /* Enable error reporting for expressions and declarations with an implied 'any' type. */, 26 | "strictNullChecks": true /* When type checking, take into account 'null' and 'undefined'. */, 27 | "strictFunctionTypes": true /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */, 28 | "strictBindCallApply": true /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */, 29 | "strictPropertyInitialization": true /* Check for class properties that are declared but not set in the constructor. */, 30 | "noImplicitThis": true /* Enable error reporting when 'this' is given the type 'any'. */, 31 | "useUnknownInCatchVariables": true /* Default catch clause variables as 'unknown' instead of 'any'. */, 32 | "noUnusedParameters": true /* Raise an error when a function parameter isn't read. */, 33 | "exactOptionalPropertyTypes": true /* Interpret optional property types as written, rather than adding 'undefined'. */, 34 | "noImplicitReturns": true /* Enable error reporting for codepaths that do not explicitly return in a function. */, 35 | "noFallthroughCasesInSwitch": true /* Enable error reporting for fallthrough cases in switch statements. */, 36 | "noUncheckedIndexedAccess": true /* Add 'undefined' to a type when accessed using an index. */, 37 | "noImplicitOverride": true /* Ensure overriding members in derived classes are marked with an override modifier. */, 38 | "noPropertyAccessFromIndexSignature": true /* Enforces using indexed accessors for keys declared using an indexed type. */ 39 | 40 | /* Completeness */ 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /test/memoize-test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | 3 | import { 4 | memoize, 5 | getAffectedPaths, 6 | type IMemoizeOptions, 7 | type IWatcher, 8 | } from '../src'; 9 | 10 | type StatsResult = 'hit' | 'miss' | undefined; 11 | 12 | class Stats> 13 | implements IMemoizeOptions 14 | { 15 | private privResult: StatsResult; 16 | private paramPaths: Array> | undefined; 17 | 18 | public onHit(): void { 19 | if (this.privResult !== undefined) { 20 | throw new Error('Stats not clean'); 21 | } 22 | this.privResult = 'hit'; 23 | } 24 | 25 | public onMiss(watcher: IWatcher, params: Params): void { 26 | if (this.privResult !== undefined) { 27 | throw new Error('Stats not clean'); 28 | } 29 | this.privResult = 'miss'; 30 | this.paramPaths = params.map((param) => getAffectedPaths(watcher, param)); 31 | } 32 | 33 | public get result(): StatsResult { 34 | const result = this.privResult; 35 | this.privResult = undefined; 36 | return result; 37 | } 38 | 39 | public getAffectedPaths(index: number): ReadonlyArray | undefined { 40 | return this.paramPaths?.[index]; 41 | } 42 | } 43 | 44 | test('memoizing non-objects', (t) => { 45 | const stats = new Stats(); 46 | const fn = memoize((a: number, b: number): number => a + b, stats); 47 | 48 | t.is(fn(1, 2), 3, 'cold cache'); 49 | t.is(stats.result, 'miss'); 50 | 51 | t.is(fn(1, 2), 3, 'cache hit'); 52 | t.is(stats.result, 'hit'); 53 | 54 | t.is(fn(1, 3), 4, 'cache miss'); 55 | t.is(stats.result, 'miss'); 56 | }); 57 | 58 | test('memoizing objects', (t) => { 59 | type Input = Partial<{ 60 | x: number | undefined; 61 | y: number | undefined; 62 | }>; 63 | const stats = new Stats(); 64 | const fn = memoize( 65 | (a: Input, b: Input): Input => ({ 66 | x: a.x, 67 | y: b.y, 68 | }), 69 | stats, 70 | ); 71 | 72 | const first = { x: 1 }; 73 | 74 | t.deepEqual(fn(first, { y: 2 }), { x: 1, y: 2 }, 'cache miss'); 75 | t.is(stats.result, 'miss'); 76 | t.snapshot(stats.getAffectedPaths(0), 'first parameter paths'); 77 | t.snapshot(stats.getAffectedPaths(1), 'second parameter paths'); 78 | 79 | t.deepEqual( 80 | fn({ x: 1, y: -1 }, { x: -1, y: 2 }), 81 | { x: 1, y: 2 }, 82 | 'global cache hit', 83 | ); 84 | t.is(stats.result, 'hit'); 85 | 86 | t.deepEqual(fn(first, { y: 2 }), { x: 1, y: 2 }, 'keyed cache hit'); 87 | t.is(stats.result, 'hit'); 88 | 89 | t.deepEqual(fn({ x: 2 }, { y: 2 }), { x: 2, y: 2 }, 'cache miss'); 90 | t.is(stats.result, 'miss'); 91 | 92 | t.deepEqual(fn(first, { y: 1 }), { x: 1, y: 1 }, 'keyed cache miss'); 93 | t.is(stats.result, 'miss'); 94 | }); 95 | 96 | test('unused params', (t) => { 97 | type Input = Partial<{ 98 | x: number | undefined; 99 | y: number | undefined; 100 | }>; 101 | const stats = new Stats(); 102 | const fn = memoize( 103 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 104 | (a: Input, _: Input): Input => ({ 105 | x: a.x, 106 | y: a.y, 107 | }), 108 | stats, 109 | ); 110 | 111 | t.deepEqual(fn({ x: 1, y: 2 }, { y: 3 }), { x: 1, y: 2 }, 'cache miss'); 112 | t.is(stats.result, 'miss'); 113 | t.snapshot(stats.getAffectedPaths(0), 'first parameter paths'); 114 | t.snapshot(stats.getAffectedPaths(1), 'second parameter paths'); 115 | 116 | t.deepEqual(fn({ x: 1, y: 2 }, { y: 4 }), { x: 1, y: 2 }, 'cache hit'); 117 | t.is(stats.result, 'hit'); 118 | 119 | t.deepEqual(fn({ x: 1, y: 3 }, { y: 4 }), { x: 1, y: 3 }, 'cache miss'); 120 | t.is(stats.result, 'miss'); 121 | }); 122 | 123 | test('nested calls', (t) => { 124 | type Input = Partial<{ 125 | x: number | undefined; 126 | y: number | undefined; 127 | }>; 128 | 129 | const innerStats = new Stats(); 130 | const inner = memoize((a: Input): number | undefined => { 131 | return a.x; 132 | }, innerStats); 133 | 134 | const outerStats = new Stats(); 135 | const outer = memoize( 136 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 137 | (a: Input): number | undefined => inner(a), 138 | outerStats, 139 | ); 140 | 141 | t.is(outer({ x: 1, y: 2 }), 1, 'cache miss'); 142 | t.is(innerStats.result, 'miss'); 143 | t.is(outerStats.result, 'miss'); 144 | t.snapshot(innerStats.getAffectedPaths(0), 'first inner parameter paths'); 145 | t.snapshot(outerStats.getAffectedPaths(0), 'first outer parameter paths'); 146 | 147 | t.deepEqual(outer({ x: 1, y: 3 }), 1, 'cache hit'); 148 | t.is(outerStats.result, 'hit'); 149 | 150 | t.deepEqual(outer({ x: 2, y: 2 }), 2, 'cache miss'); 151 | t.is(innerStats.result, 'miss'); 152 | t.is(outerStats.result, 'miss'); 153 | }); 154 | -------------------------------------------------------------------------------- /test/watcher-test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | 3 | import { watch, watchAll, getAffectedPaths } from '../src'; 4 | 5 | test('placing sub-property object into wrapped result', (t) => { 6 | const input = { 7 | x: { 8 | y: 1, 9 | }, 10 | z: 2, 11 | }; 12 | 13 | const { proxy, watcher } = watch(input); 14 | 15 | const derived = watcher.unwrap({ 16 | y: proxy.x.y, 17 | }); 18 | watcher.stop(); 19 | 20 | t.is(derived.y, input.x.y); 21 | t.deepEqual(getAffectedPaths(watcher, input), ['$.x.y']); 22 | 23 | t.false(watcher.isChanged(input, input), 'input should be equal to itself'); 24 | t.false( 25 | watcher.isChanged(input, { ...input, z: 3 }), 26 | 'unrelated properties should not be taken in account', 27 | ); 28 | t.false( 29 | watcher.isChanged(input, { 30 | x: { 31 | y: 1, 32 | }, 33 | z: 3, 34 | }), 35 | 'replacing deeply accessed subojects should not cause invalidation', 36 | ); 37 | t.true( 38 | watcher.isChanged(input, { 39 | x: { 40 | y: 2, 41 | }, 42 | z: 3, 43 | }), 44 | 'replacing deeply accessed property should cause invalidation', 45 | ); 46 | }); 47 | 48 | test('placing and accessing sub-property object into wrapped result', (t) => { 49 | const input = { 50 | x: { 51 | y: 1, 52 | }, 53 | z: 2, 54 | }; 55 | 56 | const { proxy, watcher } = watch(input); 57 | 58 | const derived = watcher.unwrap({ 59 | x: proxy.x, 60 | y: proxy.x.y, 61 | }); 62 | 63 | // Touch nested property after unwrap to make sure that the `proxy.x` stays 64 | // in `kSelf` terminal mode. 65 | t.is(proxy.x.y, 1); 66 | 67 | watcher.stop(); 68 | 69 | t.is(derived.x, input.x); 70 | t.is(derived.y, input.x.y); 71 | t.deepEqual(getAffectedPaths(watcher, input), ['$.x']); 72 | 73 | t.false(watcher.isChanged(input, input), 'input should be equal to itself'); 74 | t.false( 75 | watcher.isChanged(input, { ...input, z: 3 }), 76 | 'unrelated properties should not be taken in account', 77 | ); 78 | t.true( 79 | watcher.isChanged(input, { 80 | x: { 81 | y: 1, 82 | }, 83 | z: 3, 84 | }), 85 | 'replacing fully-copied subojects should cause invalidation', 86 | ); 87 | }); 88 | 89 | test('nested wraps', (t) => { 90 | const input = { 91 | x: { 92 | y: 1, 93 | }, 94 | z: 2, 95 | }; 96 | 97 | const { proxy: p1, watcher: w1 } = watch(input); 98 | const { proxy: p2, watcher: w2 } = watch(p1.x); 99 | 100 | const derived = w1.unwrap({ 101 | y: w2.unwrap(p2.y), 102 | }); 103 | 104 | w2.stop(); 105 | 106 | t.is(derived.y, 1); 107 | t.deepEqual(getAffectedPaths(w1, input), ['$.x.y']); 108 | 109 | t.false(w2.isChanged(p1.x, p1.x), 'outer: proxy should be equal to itself'); 110 | t.false( 111 | w2.isChanged(p1.x, { y: 1 }), 112 | 'outer: proxy should be equal to its copy', 113 | ); 114 | t.true( 115 | w2.isChanged(p1.x, { y: 2 }), 116 | 'outer: value different from proxy should be detected', 117 | ); 118 | 119 | t.false( 120 | w2.isChanged(p1.x, input.x), 121 | 'outer: input should be equal to itself', 122 | ); 123 | t.false( 124 | w2.isChanged(p1.x, { y: 1 }), 125 | 'outer: input should be equal to its copy', 126 | ); 127 | t.true( 128 | w2.isChanged(p1.x, { y: 2 }), 129 | 'outer: value different from input should be detected', 130 | ); 131 | 132 | w1.stop(); 133 | 134 | t.false(w1.isChanged(input, input), 'inner: input should be equal to itself'); 135 | t.false( 136 | w1.isChanged(input, { ...input, z: 3 }), 137 | 'unrelated properties should not be taken in account', 138 | ); 139 | t.false( 140 | w1.isChanged(input, { 141 | x: { 142 | y: 1, 143 | }, 144 | z: 3, 145 | }), 146 | 'replacing deeply accessed subojects should not cause invalidation', 147 | ); 148 | t.true( 149 | w1.isChanged(input, { 150 | x: { 151 | y: 2, 152 | }, 153 | z: 3, 154 | }), 155 | 'replacing deeply accessed property should cause invalidation', 156 | ); 157 | }); 158 | 159 | test('nested wraps with self use', (t) => { 160 | const input = { 161 | x: { 162 | y: 1, 163 | }, 164 | z: 2, 165 | }; 166 | 167 | const { proxy: p1, watcher: w1 } = watch(input); 168 | const { proxy: p2, watcher: w2 } = watch(p1.x); 169 | 170 | const derived = w1.unwrap({ 171 | x: w2.unwrap(p2), 172 | }); 173 | 174 | w2.stop(); 175 | 176 | t.is(derived.x.y, 1); 177 | t.deepEqual(getAffectedPaths(w1, input), ['$.x']); 178 | t.deepEqual(getAffectedPaths(w2, input.x), ['$']); 179 | 180 | t.false( 181 | w2.isChanged(input.x, input.x), 182 | 'outer: proxy should be equal to itself', 183 | ); 184 | t.false( 185 | w2.isChanged(input.x, p1.x), 186 | 'outer: proxy should be equal to proxy of itself', 187 | ); 188 | t.true( 189 | w2.isChanged(input.x, { y: 1 }), 190 | 'outer: proxy should not be equal to its copy', 191 | ); 192 | 193 | w1.stop(); 194 | 195 | t.false(w1.isChanged(input, input), 'inner: input should be equal to itself'); 196 | t.false( 197 | w1.isChanged(input, { ...input, z: 3 }), 198 | 'unrelated properties should not be taken in account', 199 | ); 200 | t.true( 201 | w1.isChanged(input, { 202 | x: { 203 | y: 1, 204 | }, 205 | z: 3, 206 | }), 207 | 'replacing deeply accessed object should cause invalidation', 208 | ); 209 | }); 210 | 211 | test('nested wraps with non-configurable properties', (t) => { 212 | const input = [1, 2, 3]; 213 | const { proxy: p1, watcher: w1 } = watch(input); 214 | const { proxy: p2, watcher: w2 } = watch(p1); 215 | 216 | const derived = w1.unwrap(w2.unwrap(p2.filter((x) => x > 1))); 217 | 218 | w2.stop(); 219 | w1.stop(); 220 | 221 | t.deepEqual(derived, [2, 3]); 222 | 223 | const affected = [ 224 | '$:has(0)', 225 | '$:has(1)', 226 | '$:has(2)', 227 | '$.filter', 228 | '$.length', 229 | '$.constructor', 230 | '$.0', 231 | '$.1', 232 | '$.2', 233 | ]; 234 | t.deepEqual(getAffectedPaths(w1, input), affected); 235 | t.deepEqual(getAffectedPaths(w2, input), affected); 236 | }); 237 | 238 | test('comparing arrays', (t) => { 239 | const input: Array<{ x: number; y?: number }> = [{ x: 1 }, { x: 2 }]; 240 | const { proxy, watcher } = watch(input); 241 | const derived = watcher.unwrap({ 242 | x: proxy[1]?.x, 243 | }); 244 | watcher.stop(); 245 | 246 | t.is(derived.x, 2); 247 | t.deepEqual(getAffectedPaths(watcher, input), ['$.1.x']); 248 | 249 | t.false(watcher.isChanged(input, input), 'same input'); 250 | t.false( 251 | watcher.isChanged(input, [{ x: 3 }, { x: 2 }]), 252 | 'same property at [1]', 253 | ); 254 | t.false( 255 | watcher.isChanged(input, [{ x: 3 }, { x: 2, y: 3 }]), 256 | 'extra property at [1]', 257 | ); 258 | t.true( 259 | watcher.isChanged(input, [{ x: 3 }, { x: 3 }]), 260 | 'different property at [1]', 261 | ); 262 | t.true(watcher.isChanged(input, [{ x: 3 }]), 'different length'); 263 | }); 264 | 265 | test('accessing own keys', (t) => { 266 | const input: Partial<{ 267 | a: number; 268 | b: number; 269 | c: number; 270 | }> = { 271 | a: 1, 272 | b: 2, 273 | }; 274 | 275 | const { proxy, watcher } = watch(input); 276 | const derived = watcher.unwrap({ 277 | // This should not contribute to affected paths 278 | hasA: Object.hasOwn(proxy, 'a'), 279 | 280 | keys: Reflect.ownKeys(proxy).sort(), 281 | 282 | // This should not contribute to affected paths 283 | hasB: Object.hasOwn(proxy, 'a'), 284 | }); 285 | watcher.stop(); 286 | 287 | t.true(derived.hasA); 288 | t.deepEqual(derived.keys, ['a', 'b']); 289 | t.true(derived.hasB); 290 | t.deepEqual(getAffectedPaths(watcher, input), ['$:allOwnKeys']); 291 | 292 | t.false(watcher.isChanged(input, input), 'input should be equal to itself'); 293 | t.false( 294 | watcher.isChanged(input, { a: 2, b: 3 }), 295 | 'changed values should not trigger invalidation', 296 | ); 297 | 298 | const cInProto = new (class { 299 | a = 1; 300 | b = 1; 301 | 302 | public get c() { 303 | return 2; 304 | } 305 | })(); 306 | t.false( 307 | watcher.isChanged(input, cInProto), 308 | 'different prototype keys should not trigger invalidation', 309 | ); 310 | 311 | t.true( 312 | watcher.isChanged(input, { a: 1, b: 2, c: 3 }), 313 | 'added keys should trigger invalidation', 314 | ); 315 | t.true( 316 | watcher.isChanged(input, { a: 1 }), 317 | 'removed keys should trigger invalidation', 318 | ); 319 | t.true( 320 | watcher.isChanged(input, { b: 2, c: 3 }), 321 | 'different keys should trigger invalidation', 322 | ); 323 | 324 | const bInProto = new (class { 325 | a = 1; 326 | 327 | public get b() { 328 | return 2; 329 | } 330 | 331 | c = 1; 332 | })(); 333 | t.true( 334 | watcher.isChanged(input, bInProto), 335 | 'missing own keys present in prototype should trigger invalidation', 336 | ); 337 | }); 338 | 339 | test('skip tracking of non-objects/non-arrays', (t) => { 340 | const input = { 341 | a: new Map([['b', 1]]), 342 | c: new (class { 343 | d = 2; 344 | })(), 345 | }; 346 | const { proxy, watcher } = watch(input); 347 | const derived = watcher.unwrap({ 348 | b: proxy.a.get('b'), 349 | d: proxy.c.d, 350 | }); 351 | watcher.stop(); 352 | 353 | t.is(derived.b, 1); 354 | t.is(derived.d, 2); 355 | t.deepEqual(getAffectedPaths(watcher, input), []); 356 | }); 357 | 358 | test('comparing untracked primitives', (t) => { 359 | const { watcher } = watch({}); 360 | watcher.stop(); 361 | t.deepEqual(getAffectedPaths(watcher, true), []); 362 | t.deepEqual(getAffectedPaths(watcher, null), []); 363 | 364 | t.false(watcher.isChanged(true, true)); 365 | t.true(watcher.isChanged(true, false)); 366 | t.false(watcher.isChanged(null, null)); 367 | t.true(watcher.isChanged(null, { a: 1 })); 368 | }); 369 | 370 | test('comparing untracked objects', (t) => { 371 | const { watcher } = watch({}); 372 | watcher.stop(); 373 | 374 | t.false(watcher.isChanged({}, {})); 375 | t.deepEqual(getAffectedPaths(watcher, {}), []); 376 | 377 | const same = {}; 378 | t.false(watcher.isChanged(same, same)); 379 | }); 380 | 381 | test('it supports "in"', (t) => { 382 | const input: Partial<{ a: number; b: number; c: number }> = { a: 1 }; 383 | 384 | const { proxy, watcher } = watch(input); 385 | 386 | const derived = watcher.unwrap({ 387 | hasA: 'a' in proxy, 388 | hasB: 'b' in proxy, 389 | }); 390 | 391 | watcher.stop(); 392 | 393 | t.true(derived.hasA); 394 | t.false(derived.hasB); 395 | t.deepEqual(getAffectedPaths(watcher, input), ['$:has(a)', '$:has(b)']); 396 | 397 | t.false(watcher.isChanged(input, { a: 1 }), 'copied input has the key'); 398 | t.false(watcher.isChanged(input, { a: 2 }), 'different value is ignored'); 399 | t.false( 400 | watcher.isChanged(input, { a: 1, c: 3 }), 401 | 'new properties are ignored', 402 | ); 403 | t.true(watcher.isChanged(input, {}), 'missing property is not ignored'); 404 | t.true( 405 | watcher.isChanged(input, { a: 1, b: 1 }), 406 | 'added property is not ignored', 407 | ); 408 | }); 409 | 410 | test('own property descriptor access', (t) => { 411 | const input: Partial<{ a: number; b: number }> = { a: 1 }; 412 | 413 | const { proxy, watcher } = watch(input); 414 | 415 | const derived = watcher.unwrap({ 416 | hasA: Object.hasOwn(proxy, 'a'), 417 | hasB: Object.hasOwn(proxy, 'b'), 418 | a: proxy.a, 419 | }); 420 | watcher.stop(); 421 | 422 | t.true(derived.hasA); 423 | t.false(derived.hasB); 424 | t.is(derived.a, input.a); 425 | t.deepEqual(getAffectedPaths(watcher, input), [ 426 | '$:hasOwn(a)', 427 | '$:hasOwn(b)', 428 | '$.a', 429 | ]); 430 | 431 | t.false(watcher.isChanged(input, input), 'self comparison returns true'); 432 | t.false(watcher.isChanged(input, { a: 1 }), 'same own property'); 433 | 434 | t.true(watcher.isChanged(input, { a: 2 }), 'different own property'); 435 | const aInProto = new (class { 436 | public get a() { 437 | return 1; 438 | } 439 | 440 | b = 2; 441 | })(); 442 | t.true(watcher.isChanged(input, aInProto), 'not own property anymore'); 443 | t.true(watcher.isChanged(input, { a: 1, b: 2 }), 'old property not in proto'); 444 | }); 445 | 446 | test('frozen objects', (t) => { 447 | const input = Object.freeze({ 448 | a: Object.freeze([1]), 449 | }); 450 | 451 | for (const prefix of ['fresh', 'cached']) { 452 | const { proxy, watcher } = watch(input); 453 | 454 | const derived = watcher.unwrap({ 455 | b: proxy.a[0], 456 | }); 457 | watcher.stop(); 458 | 459 | t.is(derived.b, input.a[0]); 460 | t.deepEqual(getAffectedPaths(watcher, input), ['$.a.0']); 461 | 462 | t.false( 463 | watcher.isChanged(input, input), 464 | `${prefix}: self comparison returns true`, 465 | ); 466 | t.false( 467 | watcher.isChanged(input, { a: [1, 2] }), 468 | `${prefix}: same deep property`, 469 | ); 470 | 471 | t.true( 472 | watcher.isChanged(input, { a: [2] }), 473 | `${prefix}: different deep property`, 474 | ); 475 | } 476 | }); 477 | 478 | test('wrapAll', (t) => { 479 | const a = { x: 1 }; 480 | const b = { x: 1 }; 481 | const c = { x: 1 }; 482 | 483 | const { proxies, watcher } = watchAll([a, b, c]); 484 | 485 | const derived = watcher.unwrap({ 486 | a: proxies[0]?.x, 487 | b: proxies[1]?.x, 488 | }); 489 | watcher.stop(); 490 | 491 | t.is(derived.a, a.x); 492 | t.is(derived.b, b.x); 493 | t.deepEqual(getAffectedPaths(watcher, a), ['$.x']); 494 | t.deepEqual(getAffectedPaths(watcher, b), ['$.x']); 495 | t.deepEqual(getAffectedPaths(watcher, c), []); 496 | 497 | t.false(watcher.isChanged(a, { x: 1 }), 'copy of first object'); 498 | t.false(watcher.isChanged(b, { x: 1 }), 'copy of second object'); 499 | t.false(watcher.isChanged(c, { x: 1 }), 'copy of third object'); 500 | t.false(watcher.isChanged(c, { x: 2 }), 'changed third object'); 501 | 502 | t.true(watcher.isChanged(a, { x: 2 }), 'changed first object'); 503 | t.true(watcher.isChanged(b, { x: 2 }), 'changed second object'); 504 | }); 505 | 506 | test('unwrapping the proxy itself', (t) => { 507 | const input = { 508 | x: { 509 | y: 1, 510 | }, 511 | z: 2, 512 | }; 513 | 514 | const { proxy, watcher } = watch(input); 515 | 516 | const derived = watcher.unwrap(proxy); 517 | watcher.stop(); 518 | 519 | t.is(derived, input); 520 | 521 | t.deepEqual(getAffectedPaths(watcher, input), ['$']); 522 | 523 | t.false(watcher.isChanged(input, input), 'input should be equal to itself'); 524 | t.true( 525 | watcher.isChanged(input, { ...input }), 526 | 'copied input should be not be equal', 527 | ); 528 | }); 529 | 530 | test('circular object', (t) => { 531 | type Circular = { 532 | self?: Circular; 533 | value: Value; 534 | }; 535 | 536 | const input = { a: { b: 1 } }; 537 | const { proxy, watcher } = watch(input); 538 | 539 | const circular: Circular = { 540 | value: proxy.a, 541 | }; 542 | circular.self = circular; 543 | 544 | const derived = watcher.unwrap(circular); 545 | watcher.stop(); 546 | 547 | t.is(derived.self, derived); 548 | t.is(derived.value, input.a); 549 | }); 550 | 551 | test('disallowed updates', (t) => { 552 | const input: Partial<{ a: number; b: number }> = { a: 1 }; 553 | 554 | t.throws(() => (watch(input).proxy.a = 1)); 555 | t.throws(() => delete watch(input).proxy.a); 556 | t.throws(() => Object.defineProperty(watch(input).proxy, 'a', {})); 557 | }); 558 | 559 | test('revoked proxies', (t) => { 560 | const input: Partial<{ a: number; b: number }> = { a: 1 }; 561 | 562 | const { proxy, watcher } = watch(input); 563 | watcher.stop(); 564 | 565 | t.throws(() => proxy.a); 566 | }); 567 | 568 | test('getAffectedPaths ignores non-Watcher instances', (t) => { 569 | const notWatcher = { 570 | unwrap(result: Result): Result { 571 | return result; 572 | }, 573 | // eslint-disable-next-line @typescript-eslint/no-empty-function 574 | stop() {}, 575 | isChanged() { 576 | return false; 577 | }, 578 | }; 579 | t.deepEqual(getAffectedPaths(notWatcher, {}), []); 580 | }); 581 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { hasSameOwnKeys, returnFalse, isObject, maybeUnfreeze } from './util'; 2 | import { 3 | get as reflectGet, 4 | getOwnPropertyDescriptor as reflectDescriptor, 5 | has as reflectHas, 6 | ownKeys as reflectOwnKeys, 7 | } from './reflect'; 8 | 9 | /** 10 | * Result of `watch(value)` call. 11 | * 12 | * @public 13 | * @typeParam Value - Type of the value. 14 | * @see {@link watch} for usage examples. 15 | */ 16 | export type WatchResult = Readonly<{ 17 | /** 18 | * Proxy object for the input value. Use this to derive data from it while 19 | * tracking the property access. 20 | */ 21 | proxy: Value; 22 | 23 | /** 24 | * The watcher object associated with the `proxy`. 25 | * 26 | * @see {@link IWatcher} for API reference. 27 | */ 28 | watcher: IWatcher; 29 | }>; 30 | 31 | /** 32 | * Result of `watchAll(values)` call. 33 | * 34 | * @public 35 | * @typeParam Value - Type of the value. 36 | * @see {@link watchAll} for usage examples. 37 | */ 38 | export type WatchAllResult> = Readonly<{ 39 | /** 40 | * Proxy objects for the input values. 41 | * 42 | * @see {@link WatchResult} for details. 43 | */ 44 | proxies: Values; 45 | 46 | /** 47 | * The watcher object associated with `proxies`. 48 | * 49 | * @see {@link IWatcher} for API reference. 50 | */ 51 | watcher: IWatcher; 52 | }>; 53 | 54 | /** 55 | * API interface for `watcher` property of `WatchResult` and `WatchAllResult`. 56 | * 57 | * @public 58 | */ 59 | export interface IWatcher { 60 | /** 61 | * Unwraps the result derived from the input value proxy. 62 | * 63 | * The `result` object may contain proxies if sub-objects of the input value 64 | * proxy were put into the derived data as they are. This method will replace 65 | * all proxy occurences with their underlying object's value so that the 66 | * final result is proxy-free. 67 | * 68 | * @param result - The derived result object that might contain proxies in its 69 | * sub-properties. 70 | * @returns The result without proxies in any of its deep sub-properties. 71 | */ 72 | unwrap(result: Result): Result; 73 | 74 | /** 75 | * Stops tracking of property access for the proxies associated with this 76 | * watcher. Accessing proxy (or sub-proxy) properties after calling 77 | * `watcher.stop()` will generate a runtime error. 78 | * 79 | * Use this to make sure at runtime that no Proxy ends up in your derived 80 | * data. 81 | */ 82 | stop(): void; 83 | 84 | /** 85 | * Returns `true` if tracked sub-properties of `oldValue` are different from 86 | * (or not present in) the `newValue`. `oldValue` must be either the 87 | * `value` parameter of `watch` (or one of the `values` of `watchAll`) or 88 | * a sub-object of it. 89 | * 90 | * @see {@link watch} 91 | * @see {@link watchAll} 92 | * 93 | * @param oldValue - `value` argument of `watch`/`watchAll` or its sub-object. 94 | * @param newValue - any object or primitive to compare against. 95 | * @returns `true` if tracked properties are different, otherwise - `false`. 96 | */ 97 | isChanged(oldValue: Value, newValue: Value): boolean; 98 | } 99 | 100 | type AbstractRecord = Record; 101 | 102 | // `kSource` is key for Proxy's get() handler to return the original object 103 | // skipping all proxies that wrap it. We declare it on module level so that 104 | // multiple instances of `Watcher` all track the source correctly. 105 | const kSource: unique symbol = Symbol(); 106 | 107 | // `kTrack` is the name of the method on `Watcher and it is exported for 108 | // `watch`/`watchAll` to be able to call into what otherwise would have been a 109 | // private method of `Watcher` class. 110 | const kTrack: unique symbol = Symbol(); 111 | 112 | // `kTouched` is the name of the property holding the WeakMap of `TouchedEntry`. 113 | // It is accessed by `getAffectedPaths` outside of `Watcher` class so we have 114 | // to have it available outside of the `Watcher`. 115 | const kTouched: unique symbol = Symbol(); 116 | 117 | // If `kSelf` is present in place of `TouchedEntry` in `kTouched` map - it means 118 | // that whole object was used and there is no point in checking individual keys 119 | // anymore since we have to do `===` check on the objects themselves. 120 | // We set these during `unwrap()` call. 121 | const kSelf = true; 122 | const kAllOwnKeys = true; 123 | 124 | type TouchedEntry = { 125 | // Set of keys for properties that were accessed. 126 | readonly keys: Set; 127 | 128 | // Set of keys that were checked with `in` operator. 129 | readonly has: Set; 130 | 131 | // Set of keys that were checked with `Object.hasOwn` function or 132 | // `kAllOwnKeys` if `Object.keys()` was called on proxy. 133 | hasOwn: Set | typeof kAllOwnKeys; 134 | }; 135 | 136 | const getSource = (value: Value): Value => { 137 | if (!isObject(value)) { 138 | return value; 139 | } 140 | 141 | const source = (value as Record)[kSource]; 142 | return source ?? value; 143 | }; 144 | 145 | class Watcher implements IWatcher { 146 | readonly #proxyMap = new WeakMap(); 147 | 148 | #revokes: Array<() => void> = []; 149 | 150 | /** @internal */ 151 | public readonly [kTouched] = new WeakMap< 152 | object, 153 | TouchedEntry | typeof kSelf 154 | >(); 155 | 156 | /** 157 | * @see {@link IWatcher} 158 | * @internal 159 | */ 160 | public unwrap(result: Result): Result { 161 | return this.#unwrap(result, new Set()); 162 | } 163 | 164 | /** 165 | * @see {@link IWatcher} 166 | * @internal 167 | */ 168 | public stop(): void { 169 | const revokes = this.#revokes; 170 | this.#revokes = []; 171 | for (const revoke of revokes) { 172 | revoke(); 173 | } 174 | } 175 | 176 | /** 177 | * @see {@link IWatcher} 178 | * @internal 179 | */ 180 | public isChanged(oldValue: Value, newValue: Value): boolean { 181 | // Primitives or functions 182 | if (!isObject(oldValue) || !isObject(newValue)) { 183 | return oldValue !== newValue; 184 | } 185 | 186 | const oldSource = getSource(oldValue); 187 | const newSource = getSource(newValue); 188 | 189 | // Fast case! 190 | if (oldSource === newSource) { 191 | return false; 192 | } 193 | 194 | const touched = this[kTouched].get(oldSource); 195 | 196 | // Object wasn't touched - assuming it is the same. 197 | if (touched === undefined) { 198 | return false; 199 | } 200 | 201 | // We checked that the objects are different above. 202 | if (touched === kSelf) { 203 | return true; 204 | } 205 | 206 | const oldRecord = oldSource as AbstractRecord; 207 | const newRecord = newSource as AbstractRecord; 208 | 209 | if (touched.hasOwn === kAllOwnKeys) { 210 | if (!hasSameOwnKeys(oldSource, newSource)) { 211 | return true; 212 | } 213 | } else { 214 | for (const key of touched.hasOwn) { 215 | const hasOld = reflectDescriptor(oldRecord, key) !== undefined; 216 | const hasNew = reflectDescriptor(newRecord, key) !== undefined; 217 | 218 | if (hasOld !== hasNew) { 219 | return true; 220 | } 221 | 222 | // For simplicity we assume that `getOwnPropertyDescriptor` is used only 223 | // as a check for property presence and not for the actual 224 | // value/configurable/enumerable present in the descriptor. 225 | } 226 | } 227 | 228 | for (const key of touched.has) { 229 | if (reflectHas(oldRecord, key) !== reflectHas(newRecord, key)) { 230 | return true; 231 | } 232 | } 233 | 234 | for (const key of touched.keys) { 235 | if (this.isChanged(oldRecord[key], newRecord[key])) { 236 | return true; 237 | } 238 | } 239 | 240 | return false; 241 | } 242 | 243 | /** @internal */ 244 | public [kTrack](value: Value): Value { 245 | // Primitives or functions 246 | if (!isObject(value)) { 247 | return value; 248 | } 249 | 250 | // Track only arrays and objects (no Maps, WeakMaps, or class instances). 251 | const proto = Object.getPrototypeOf(value); 252 | if (proto !== Array.prototype && proto !== Object.prototype) { 253 | return value; 254 | } 255 | 256 | const source = getSource(value); 257 | 258 | // Return cached proxy 259 | const entry = this.#proxyMap.get(source); 260 | if (entry !== undefined) { 261 | return entry as Value; 262 | } 263 | 264 | // We need to be able to return tracked value on "get" access to the object, 265 | // but for the frozen object all properties are readonly and 266 | // non-configurable so Proxy must return the original value. 267 | const unfrozen = maybeUnfreeze(value, kSource); 268 | 269 | let ignoreKey: string | symbol | undefined; 270 | 271 | const { proxy, revoke } = Proxy.revocable(unfrozen, { 272 | defineProperty: returnFalse, 273 | deleteProperty: returnFalse, 274 | preventExtensions: returnFalse, 275 | set: returnFalse, 276 | setPrototypeOf: returnFalse, 277 | 278 | get: (target, key, receiver) => { 279 | if (key === kSource) { 280 | return source; 281 | } 282 | 283 | this.#touch(source)?.keys.add(key); 284 | 285 | const result = this[kTrack](reflectGet(target, key, receiver)); 286 | 287 | // We generate proxies for objects and they cannot be extended, however 288 | // we can have nested proxies in situations where users wrap the object 289 | // multiple times. 290 | // 291 | // In this case parent proxy's [[Get]] implementation will: 292 | // 1. Call this "get" trap on the child proxy (through Reflect.get 293 | // above) 294 | // 2. Call [[GetOwnProperty] on the child proxy which will call the 295 | // "getOwnPropertyDescriptor" trap in turn. 296 | // 297 | // We treat "getOwnPropertyDescriptor" calls as checks for own property, 298 | // so we ignore these as false positives, and as an extra safety check - 299 | // we compare that the key was the same. 300 | // 301 | // See: https://262.ecma-international.org/6.0/#sec-9.5.8 302 | if (receiver !== proxy) { 303 | ignoreKey = key; 304 | } 305 | return result; 306 | }, 307 | getOwnPropertyDescriptor: (target, key) => { 308 | const oldKey = ignoreKey; 309 | ignoreKey = undefined; 310 | if (oldKey !== key && key !== kSource) { 311 | const hasOwn = this.#touch(source)?.hasOwn; 312 | if (hasOwn !== kAllOwnKeys) { 313 | hasOwn?.add(key); 314 | } 315 | } 316 | 317 | return reflectDescriptor(target, key); 318 | }, 319 | has: (target, key) => { 320 | this.#touch(source)?.has.add(key); 321 | return reflectHas(target, key); 322 | }, 323 | ownKeys: (target) => { 324 | const entry = this.#touch(source); 325 | if (entry) { 326 | entry.hasOwn = kAllOwnKeys; 327 | } 328 | return reflectOwnKeys(target); 329 | }, 330 | }); 331 | 332 | this.#proxyMap.set(source, proxy); 333 | this.#revokes.push(revoke); 334 | return proxy as Value; 335 | } 336 | 337 | #touch(source: object): TouchedEntry | undefined { 338 | let touched = this[kTouched].get(source); 339 | if (touched === kSelf) { 340 | return undefined; 341 | } 342 | if (touched === undefined) { 343 | touched = { 344 | keys: new Set(), 345 | hasOwn: new Set(), 346 | has: new Set(), 347 | }; 348 | this[kTouched].set(source, touched); 349 | } 350 | return touched; 351 | } 352 | 353 | #unwrap(result: Result, visited: Set): Result { 354 | // Primitives and functions 355 | if (!isObject(result)) { 356 | return result; 357 | } 358 | const source = getSource(result); 359 | 360 | // If it was a proxy - just unwrap it 361 | if (this.#proxyMap.has(source)) { 362 | // But mark the proxy as fully used. 363 | this[kTouched].set(source, kSelf); 364 | return source; 365 | } 366 | 367 | // If object is circular - we will process its properties in the parent 368 | // caller that originally added it to `visited` set. 369 | if (visited.has(result)) { 370 | return result; 371 | } 372 | visited.add(result); 373 | 374 | // Generated object 375 | for (const key of reflectOwnKeys(result)) { 376 | const value = (result as AbstractRecord)[key]; 377 | const unwrappedValue = this.#unwrap(value, visited); 378 | 379 | // `value` was a proxy - we already marked it as `kSelf` and now we have 380 | // to updated the derived object and touch the property in it. 381 | if (unwrappedValue !== value) { 382 | (result as AbstractRecord)[key] = unwrappedValue; 383 | this.#touch(result)?.keys.add(key); 384 | } 385 | } 386 | 387 | return result; 388 | } 389 | } 390 | 391 | /** 392 | * Wraps the `value` into a proxy and returns a `WatchResult` to track the 393 | * property (and sub-property) access of the object and compare objects. 394 | * 395 | * @see {@link WatchResult} 396 | * @param value - input value which could be a plain object, array, function or 397 | * a primitive. 398 | * @returns WatchResult object that holds the `proxy` for the `value` and 399 | * `watcher` object that tracks property access and does the 400 | * comparison. 401 | * 402 | * @example 403 | * Here's an example with different objects, but unchanged accessed properties: 404 | * ``` 405 | * import { watch } from '@indutny/sneequals'; 406 | * 407 | * const value = { a: { b: 1 } }; 408 | * const { proxy, watcher } = watch(value); 409 | * const derived = watcher.unwrap({ b: proxy.a.b }); 410 | * 411 | * // Further access to `proxy` (or its sub-proxies) would throw. 412 | * watcher.stop(); 413 | * 414 | * // Prints `{ b: 1 }` 415 | * console.log(derived); 416 | * 417 | * const sameProperties = { a: { b: 1 } }; 418 | * 419 | * // Prints `false` because these are different objects. 420 | * console.log(sameProperties === value); 421 | * 422 | * // Prints `false` because the tracked `value.a.b` didn't change. 423 | * console.log(watcher.isChanged(value, sameProperties)); 424 | * ``` 425 | */ 426 | export const watch = (value: Value): WatchResult => { 427 | const { 428 | proxies: [proxy], 429 | watcher, 430 | } = watchAll([value]); 431 | return { proxy, watcher }; 432 | }; 433 | 434 | /** 435 | * Similar to `watch(value)` this method wraps a list of values with a single 436 | * `IWatcher` instance and tracks access to properties (sub-properties) of each 437 | * individual element of the `values` list. 438 | * 439 | * @see {@link WatchAllResult} 440 | * @param values - list of input values that could be plain objects, arrays, 441 | * functions or primitives. 442 | * @returns WatchAllResult object that holds the `proxies` for the `values` and 443 | * `watcher` object that tracks property access and does the 444 | * comparison. 445 | * 446 | * @example 447 | * Here's an example with different objects, but unchanged accessed properties: 448 | * ``` 449 | * import { watchAll } from '@indutny/sneequals'; 450 | * 451 | * const values = [{ a: { b: 1 } }, { c: 2 }]; 452 | * const { proxies, watcher } = watchAll(value); 453 | * const derived = watcher.unwrap({ b: proxies[0].a.b, c: proxies[1].c }); 454 | * 455 | * // Further access to `proxy` (or its sub-proxies) would throw. 456 | * watcher.stop(); 457 | * 458 | * // Prints `{ b: 1, c: 2 }` 459 | * console.log(derived); 460 | * 461 | * // Prints `false` because the tracked `value.a.b` didn't change. 462 | * console.log(watcher.isChanged(values[0], { a: { b: 1 } })); 463 | * 464 | * // Prints `true` because the tracked `value.c` changed. 465 | * console.log(watcher.isChanged(values[1], { c: 3 })); 466 | * ``` 467 | */ 468 | export const watchAll = >( 469 | values: Values, 470 | ): WatchAllResult => { 471 | const watcher = new Watcher(); 472 | return { 473 | proxies: values.map((value) => watcher[kTrack](value)) as unknown as Values, 474 | watcher, 475 | }; 476 | }; 477 | 478 | /** 479 | * Options for `memoize()` method. 480 | * 481 | * @see {@link memoize} for additional details. 482 | */ 483 | export interface IMemoizeOptions> { 484 | /** 485 | * This optional method is called on every cache hit. 486 | * 487 | * Note that since `params` were used when creating the `watcher` - you can 488 | * use them in `watcher` API methods and in `getAffectedPaths`. 489 | * 490 | * @param watcher - the previously created `IWatcher` object created with 491 | * `watchAll(params)` API method. 492 | * @param params - an array of parameters from the previous cached call. 493 | * 494 | * @see {@link IWatcher} 495 | * @see {@link getAffectedPaths} 496 | */ 497 | onHit?(watcher: IWatcher, params: Params): void; 498 | 499 | /** 500 | * This optional method is called on every cache miss. 501 | * 502 | * Note that since `params` were used when creating the `watcher` - you can 503 | * use them in `watcher` API methods and in `getAffectedPaths`. 504 | * 505 | * @param watcher - the newly created `IWatcher` object created with 506 | * `watchAll(params)` API method. 507 | * @param params - an array of parameters that generated the cache miss. 508 | * @param pastParams - an array of parameters from the previous cache miss. 509 | * 510 | * @see {@link IWatcher} 511 | * @see {@link watchAll} 512 | * @see {@link getAffectedPaths} 513 | */ 514 | onMiss?(watcher: IWatcher, params: Params, pastParams?: Params): void; 515 | } 516 | 517 | /** 518 | * Returns memoized version of the parameter function `fn`. The memoized 519 | * function will return cached value as long as the arguments of the call have 520 | * the same tracked values as the last time. 521 | * 522 | * Memoized function has two cache levels: 523 | * - A WeakMap by first object parameter 524 | * - Global cache that only holds the last execution result. 525 | * 526 | * @see {@link IMemoizeOptions} for details on available options. 527 | * 528 | * @param fn - function to be memoized 529 | * @param options - an optional options object 530 | * @returns memoized function. 531 | * 532 | * @example 533 | * With two parameters: 534 | * ``` 535 | * import { memoize } from '@indutny/sneequals'; 536 | * 537 | * const fn = memoize((a, b) => ({ result: a.value + b.value })); 538 | * 539 | * // Prints `{ result: 3 }` 540 | * const answer = fn({ value: 1 }, { value: 2 }); 541 | * console.log(answer); 542 | * 543 | * const cachedAnswer = fn({ value: 1 }, { value: 2 }); 544 | * 545 | * // Even though the input objects are different the `cachedResult` is the 546 | * // same since the tracked properties didn't change. 547 | * // Prints `true`. 548 | * console.log(answer === cachedAnswer); 549 | * ``` 550 | * 551 | * @example 552 | * With react-redux and an id lookup. 553 | * ({@link https://github.com/dai-shi/proxy-memoize#usage-with-react-redux--reselect | Taken from proxy-memoize}.) 554 | * ``` 555 | * import { useCallback } from 'react'; 556 | * import { useSelector } from 'react-redux'; 557 | * import { memoize } from '@indutny/sneequals'; 558 | * 559 | * const Component = ({ id }) => { 560 | * const { score, title } = useSelector(useCallback(memoize(state => ({ 561 | * score: getScore(state), 562 | * title: state.titles[id], 563 | * })), [id])); 564 | * return
{score.score} {score.createdAt} {title}
; 565 | * }; 566 | * ``` 567 | */ 568 | export const memoize = , Result>( 569 | fn: (...params: Params) => Result, 570 | 571 | // Mostly for tests 572 | options?: IMemoizeOptions, 573 | ): ((...params: Params) => Result) => { 574 | type CacheEntry = Readonly<{ 575 | sources: Params; 576 | watcher: IWatcher; 577 | result: Result; 578 | }>; 579 | 580 | let cached: CacheEntry | undefined; 581 | return (...params: Params): Result => { 582 | const sources = params.map((param) => 583 | getSource(param), 584 | ) as unknown as Params; 585 | if (cached !== undefined && cached.sources.length === sources.length) { 586 | let isValid = true; 587 | for (let i = 0; i < cached.sources.length; i++) { 588 | if (cached.watcher.isChanged(cached.sources[i], sources[i])) { 589 | isValid = false; 590 | break; 591 | } 592 | } 593 | if (isValid) { 594 | options?.onHit?.(cached.watcher, cached.sources); 595 | return cached.result; 596 | } 597 | } 598 | 599 | const { proxies, watcher } = watchAll(params); 600 | const result = watcher.unwrap(fn(...proxies)); 601 | watcher.stop(); 602 | 603 | if (options?.onMiss) { 604 | options.onMiss(watcher, sources, cached?.sources); 605 | } 606 | 607 | cached = { 608 | sources, 609 | watcher, 610 | result, 611 | }; 612 | 613 | return result; 614 | }; 615 | }; 616 | 617 | /** 618 | * Returns a list of affected (accessed) JSON paths (and sub-paths) in the 619 | * `value`. This function is provided mostly as a debugging tool to see which 620 | * JSON paths might have caused the cache invalidation in `memoize()`. 621 | * 622 | * @param watcher - A watcher object that tracked the access to paths/sub-paths 623 | * of the `value` 624 | * @param value - A `value` tracked by `watcher` 625 | * 626 | * @see {@link IWatcher} 627 | * @see {@link watch} for the details on how to get an `IWatcher` instance. 628 | * @see {@link memoize} 629 | * 630 | * @example 631 | * ``` 632 | * import { watch, getAffectedPaths } from '@indutny/sneequals'; 633 | * 634 | * const value = { a: { b: 1 } }; 635 | * const { proxy, watcher } = watch(value); 636 | * 637 | * // Prints `1` 638 | * console.log(proxy.a.b); 639 | * 640 | * // Prints `['a.b']` 641 | * console.log(getAffectedPaths(watcher, value)); 642 | * ``` 643 | */ 644 | export const getAffectedPaths = ( 645 | watcher: IWatcher, 646 | value: unknown, 647 | ): ReadonlyArray => { 648 | if (!(watcher instanceof Watcher)) { 649 | return []; 650 | } 651 | 652 | const out: Array = []; 653 | getAffectedPathsInto(watcher, value, '$', out); 654 | return out; 655 | }; 656 | 657 | const getAffectedPathsInto = ( 658 | watcher: Watcher, 659 | value: unknown, 660 | path: string, 661 | out: Array, 662 | ): void => { 663 | if (!isObject(value)) { 664 | if (path !== '$') { 665 | out.push(path); 666 | } 667 | return; 668 | } 669 | 670 | const source = getSource(value); 671 | const touched = watcher[kTouched].get(source); 672 | 673 | if (touched === undefined) { 674 | return; 675 | } 676 | 677 | if (touched === kSelf) { 678 | out.push(path); 679 | return; 680 | } 681 | 682 | if (touched.hasOwn === kAllOwnKeys) { 683 | out.push(`${path}:allOwnKeys`); 684 | } else { 685 | for (const key of touched.hasOwn) { 686 | out.push(`${path}:hasOwn(${String(key)})`); 687 | } 688 | } 689 | 690 | for (const key of touched.has) { 691 | out.push(`${path}:has(${String(key)})`); 692 | } 693 | 694 | const record = source as AbstractRecord; 695 | for (const key of touched.keys) { 696 | getAffectedPathsInto(watcher, record[key], `${path}.${String(key)}`, out); 697 | } 698 | }; 699 | --------------------------------------------------------------------------------