├── .babelrc ├── .gitignore ├── .npmignore ├── README.md ├── package.json ├── src └── index.ts ├── test └── memcord.test.js ├── tsconfig.json └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["env"] 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log 3 | dist -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | test -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | memcord 2 | ======= 3 | 4 | [![Version](http://img.shields.io/npm/v/memcord.svg)](https://www.npmjs.org/package/memcord) 5 | 6 | Record objects that return reference-equal values when you repeat previous updates. 7 | 8 | Install with `npm install memcord --save`. 9 | 10 | ```js 11 | import { createMemcord } from 'memcord' 12 | 13 | // Create a new record with the given values 14 | const model = createMemcord({ value: 'koala' }) 15 | 16 | // Access values like a normal record 17 | console.log(model.value) // koala 18 | 19 | // Repeatedly setting the same value will return reference-equal objects. 20 | const newRecord1 = model.set('value', 'kangaroo') 21 | const newRecord2 = model.set('value', 'kangaroo') 22 | 23 | console.log(model.value) // koala 24 | console.log(newRecord1.value) // kangaroo 25 | console.log(newRecord2.value) // kangaroo 26 | 27 | console.log(newRecord1 === newRecord2) // true 28 | ``` 29 | 30 | 31 | Why? 32 | ---- 33 | 34 | **Memcords let you use records as React props, *without* breaking `PureComponent`.** 35 | 36 | In large React applications, it is important for performance that your props can be compared by reference equality. Without reference equality, `PureComponent` can't provide any performance wins -- making performance optimization a much harder problem. 37 | 38 | Primitive props (i.e. strings and numbers) will always work as expected, making them easy to use. However, objects present a problem; if you want to update an immutable object in each `render` cycle, they'll never be reference-equal -- even if their values are equivalent! 39 | 40 | ```js 41 | const model = { value: 'kangaroo' } 42 | 43 | const newModel1 = { value: 'koala' } 44 | const newModel2 = { value: 'koala' } 45 | 46 | console.log(newModel1 === newModel2) // false! 47 | ``` 48 | 49 | Memcords detect repeated changes and return identical values, simplifying the use of records as props. 50 | 51 | ```js 52 | // Create new record with the given values 53 | const model = createMemcord({ value: 'koala' }) 54 | 55 | // Repeatedly setting the same value will return reference-equal objects. 56 | const newRecord1 = model.set('value', 'kangaroo') 57 | const newRecord2 = model.set('value', 'kangaroo') 58 | 59 | console.log(newRecord1 === newRecord2) //true 60 | ``` 61 | 62 | Usage 63 | ----- 64 | 65 | 66 | ### `createMemcord(values, equals?)` 67 | 68 | Create a memoized record. 69 | 70 | You can also customize how the memcord checks for equality by passing in a comparison function as the second argument. By default, it will use reference equality. 71 | 72 | ```js 73 | import { createMemcord } from 'memcord' 74 | 75 | const data = createMemcord({ value: 'kangaroo' }) 76 | 77 | console.log(data.value) // 'kangaroo' 78 | console.log(data.error) // undefined 79 | 80 | 81 | const nextData = createMemcord({ value: 'kangaroo' }) 82 | 83 | // Two memcords created with separate calls to `createMemcord` 84 | // will never be equal, even if they share the same properties. 85 | console.log(nextData === data) // false 86 | ``` 87 | 88 | 89 | ### `set(key, value)` 90 | 91 | Set values with `set`. Repeating the same `set` will return the same record. 92 | 93 | ```js 94 | const newData1 = data.set('value', 'dropbear') 95 | 96 | console.log(newData1.value) // 'dropbear' 97 | console.log(newData1 !== data) // true 98 | 99 | 100 | const newData2 = data.set('value', 'dropbear') 101 | 102 | console.log(newData2.value) // 'dropbear' 103 | console.log(newData2 === newData1) // true 104 | ``` 105 | 106 | 107 | ### `merge(values)` 108 | 109 | You update multiple value at a time with `merge`. 110 | 111 | ```js 112 | const merged1 = data.merge({ value: 'giant koala', error: 'extinct' }) 113 | 114 | console.log(merged1.value) // 'giant koala' 115 | console.log(merged1.error) // 'extinct' 116 | 117 | const merged2 = data.merge({ value: 'giant koala', error: 'extinct' }) 118 | 119 | console.log(merged1 === merged2) // true 120 | ``` 121 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "memcord", 3 | "version": "1.0.0-1", 4 | "description": "Memoized Record", 5 | "author": "James K Nelson ", 6 | "license": "MIT", 7 | "main": "dist/commonjs/index.js", 8 | "module": "dist/es/index.js", 9 | "types": "src/index.ts", 10 | "scripts": { 11 | "clean": "rimraf dist", 12 | "build:commonjs": "tsc --pretty --module commonjs --outDir dist/commonjs", 13 | "build:es": "tsc --pretty --module es2015 --outDir dist/es", 14 | "build": "npm run build:es && npm run build:commonjs", 15 | "build:watch": "npm run clean && npm run build:es -- --watch", 16 | "prepublish": "npm run clean && npm run build && npm run test", 17 | "test": "mocha test/*.test.js", 18 | "test:watch": "mocha test/*.test.js" 19 | }, 20 | "keywords": [ 21 | "record", 22 | "memcord" 23 | ], 24 | "devDependencies": { 25 | "cross-env": "^5.0.5", 26 | "mocha": "^4.0.1", 27 | "rimraf": "^2.6.2", 28 | "typescript": "^2.6.2" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Create a Record that remembers values that you set for each property. 3 | * If you set the same value again, it will return the previous object. 4 | * 5 | * This allows you to call `set` multiple times with the same value, 6 | * without worrying about breaking reference equality, which can come in 7 | * handy for passing "Bus" objects through React props. 8 | */ 9 | export function createMemcord(values?: T, equals?: (x: any, y: any) => boolean): Memcord; 10 | export function createMemcord(memcord: Memcord): Memcord; 11 | export function createMemcord(valuesOrMemcord: any, equals = referenceEquals): Memcord { 12 | return ( 13 | (valuesOrMemcord instanceof MemcordBase) 14 | ? new MemcordBase(valuesOrMemcord.__values, valuesOrMemcord.__equals) as any 15 | : new MemcordBase(valuesOrMemcord || {}, equals) as any 16 | ) 17 | } 18 | 19 | export type Memcord = 20 | Readonly & { 21 | set: MemcordBase['set'] 22 | merge: MemcordBase['merge'] 23 | } 24 | 25 | const memo = new WeakMap>() 26 | 27 | type MemcordMemo = { [K in keyof T]?: { value: T[K], memcord: MemcordBase } } 28 | 29 | class MemcordBase { 30 | [name: string]: any; 31 | 32 | __equals: (x: any, y: any) => boolean; 33 | __values: T; 34 | 35 | /** 36 | * Create a new Memcord, without an empty memory. 37 | */ 38 | constructor(values: T, equals: (x: any, y: any) => boolean) { 39 | memo.set(this, {}) 40 | this.__values = values 41 | this.__equals = equals 42 | Object.assign(this, values) 43 | Object.freeze(this) 44 | } 45 | 46 | set(key: K, value: T[K]): Memcord { 47 | if (this.__equals(value, this[key])) { 48 | return this as any 49 | } 50 | else { 51 | // A value must exist on this map, as we set it in the constructor. 52 | const memcordMemo = memo.get(this) as MemcordMemo 53 | const keyMemo = memcordMemo[key] 54 | if (keyMemo && this.__equals(keyMemo.value, value)) { 55 | return keyMemo.memcord as any 56 | } 57 | else { 58 | const memcord = new MemcordBase(Object.assign({}, this.__values, { [key]: value }), this.__equals) 59 | memcordMemo[key] = { value, memcord } 60 | return memcord as any 61 | } 62 | } 63 | } 64 | 65 | merge(values: Partial): Memcord { 66 | const keys = Object.keys(values).sort() as (keyof T)[] 67 | 68 | // Find values that differ from existing values 69 | const updatedKeys: (keyof T)[] = [] 70 | for (let i=0; i = this as any 81 | while (updatedKeys.length) { 82 | let key = updatedKeys.shift() as keyof T 83 | result = result.set(key, values[key]) 84 | } 85 | return result 86 | } 87 | } 88 | 89 | function referenceEquals(a: any, b: any): boolean { 90 | return a === b 91 | } -------------------------------------------------------------------------------- /test/memcord.test.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert') 2 | const { createMemcord } = require('../dist/commonjs/index') 3 | 4 | 5 | describe('createMemcord', () => { 6 | it('returns a record', () => { 7 | const test = createMemcord({ a: 1, b: 2 }) 8 | 9 | assert.equal(test.a, 1) 10 | assert.equal(test.b, 2) 11 | assert.equal(test.c, undefined) 12 | }) 13 | 14 | it('freezes its return', () => { 15 | const test = createMemcord({ a: 1, b: 2 }) 16 | 17 | test.a = 2 18 | assert.equal(test.a, 1) 19 | }) 20 | }) 21 | 22 | 23 | describe('Memcord', () => { 24 | describe('#set', () => { 25 | let memcord 26 | 27 | beforeEach(() => { 28 | memcord = createMemcord({ a: 'ORIGINAL_A', b: 'ORIGINAL_B' }) 29 | }) 30 | 31 | it('sets keys', () => { 32 | const memcordChangedOnce = memcord.set('a', 'CHANGED') 33 | assert.equal(memcordChangedOnce.a, 'CHANGED') 34 | assert.notEqual(memcord, memcordChangedOnce) 35 | 36 | const memcordChangedTwice = memcordChangedOnce.set('a', 'CHANGED_TWICE') 37 | assert.equal(memcordChangedTwice.a, 'CHANGED_TWICE') 38 | }) 39 | 40 | it("uses existing record if value doesn't change", () => { 41 | const memcord2 = memcord.set('a', 'ORIGINAL_A') 42 | assert.equal(memcord, memcord2) 43 | }) 44 | 45 | it("remembers the last set record", () => { 46 | const firstChange = memcord.set('a', 'CHANGED') 47 | assert.notEqual(memcord, firstChange) 48 | const changeAgain = memcord.set('a', 'CHANGED') 49 | assert.equal(firstChange, changeAgain) 50 | }) 51 | 52 | it('throws when passed an unknown key', () => { 53 | assert.throws(function() { 54 | this.record.set('d', 'FAIL') 55 | }) 56 | }) 57 | }) 58 | 59 | describe('#merge', () => { 60 | let memcord 61 | 62 | beforeEach(() => { 63 | memcord = createMemcord({ a: 'ORIGINAL_A', b: 'ORIGINAL_B' }) 64 | }) 65 | 66 | it('sets keys', () => { 67 | const firstChange = memcord.merge({ a: 'CHANGED_A', c: 'CHANGED_C' }) 68 | assert.equal(firstChange.a, 'CHANGED_A') 69 | assert.equal(firstChange.b, 'ORIGINAL_B') 70 | assert.equal(firstChange.c, 'CHANGED_C') 71 | assert.notEqual(memcord, firstChange) 72 | 73 | const secondChange = firstChange.merge({ a: 'CHANGED_A', b: 'CHANGED_B' }) 74 | assert.equal(secondChange.a, 'CHANGED_A') 75 | assert.equal(secondChange.b, 'CHANGED_B') 76 | assert.equal(secondChange.c, 'CHANGED_C') 77 | }) 78 | 79 | it("uses existing record if value doesn't change", () => { 80 | const newRecord = memcord.merge({ b: 'ORIGINAL_B', a: 'ORIGINAL_A' }) 81 | assert.equal(memcord, newRecord) 82 | }) 83 | 84 | it("remembers the last set record", () => { 85 | const firstChange = memcord.merge({ a: 'CHANGED_A', c: 'CHANGED_C' }) 86 | assert.notEqual(memcord, firstChange) 87 | const changeAgain = memcord.merge({ a: 'CHANGED_A', c: 'CHANGED_C' }) 88 | assert.equal(firstChange, changeAgain) 89 | }) 90 | 91 | it('can set unknown keys', () => { 92 | let newMemcord = memcord.merge({ d: 'NEW' }) 93 | assert.equal(newMemcord.d, 'NEW') 94 | assert.notEqual(memcord, newMemcord) 95 | }) 96 | }) 97 | }) -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "es2015", 4 | "moduleResolution": "node", 5 | "outDir": "dist/es", 6 | "removeComments": false, 7 | "sourceMap": true, 8 | "strictNullChecks": true, 9 | "target": "es5", 10 | "lib": [ 11 | "es5", 12 | "es6", 13 | "dom", 14 | "dom.iterable", 15 | "es2015.collection", 16 | "es2017" 17 | ] 18 | }, 19 | "exclude": [ 20 | "dist", 21 | "test", 22 | "node_modules" 23 | ] 24 | } -------------------------------------------------------------------------------- /yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | balanced-match@^1.0.0: 6 | version "1.0.0" 7 | resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" 8 | 9 | brace-expansion@^1.1.7: 10 | version "1.1.8" 11 | resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.8.tgz#c07b211c7c952ec1f8efd51a77ef0d1d3990a292" 12 | dependencies: 13 | balanced-match "^1.0.0" 14 | concat-map "0.0.1" 15 | 16 | browser-stdout@1.3.0: 17 | version "1.3.0" 18 | resolved "https://registry.yarnpkg.com/browser-stdout/-/browser-stdout-1.3.0.tgz#f351d32969d32fa5d7a5567154263d928ae3bd1f" 19 | 20 | commander@2.11.0: 21 | version "2.11.0" 22 | resolved "https://registry.yarnpkg.com/commander/-/commander-2.11.0.tgz#157152fd1e7a6c8d98a5b715cf376df928004563" 23 | 24 | concat-map@0.0.1: 25 | version "0.0.1" 26 | resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" 27 | 28 | cross-env@^5.0.5: 29 | version "5.0.5" 30 | resolved "https://registry.yarnpkg.com/cross-env/-/cross-env-5.0.5.tgz#4383d364d9660873dd185b398af3bfef5efffef3" 31 | dependencies: 32 | cross-spawn "^5.1.0" 33 | is-windows "^1.0.0" 34 | 35 | cross-spawn@^5.1.0: 36 | version "5.1.0" 37 | resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-5.1.0.tgz#e8bd0efee58fcff6f8f94510a0a554bbfa235449" 38 | dependencies: 39 | lru-cache "^4.0.1" 40 | shebang-command "^1.2.0" 41 | which "^1.2.9" 42 | 43 | debug@3.1.0: 44 | version "3.1.0" 45 | resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261" 46 | dependencies: 47 | ms "2.0.0" 48 | 49 | diff@3.3.1: 50 | version "3.3.1" 51 | resolved "https://registry.yarnpkg.com/diff/-/diff-3.3.1.tgz#aa8567a6eed03c531fc89d3f711cd0e5259dec75" 52 | 53 | escape-string-regexp@1.0.5: 54 | version "1.0.5" 55 | resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" 56 | 57 | fs.realpath@^1.0.0: 58 | version "1.0.0" 59 | resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" 60 | 61 | glob@7.1.2, glob@^7.0.5: 62 | version "7.1.2" 63 | resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.2.tgz#c19c9df9a028702d678612384a6552404c636d15" 64 | dependencies: 65 | fs.realpath "^1.0.0" 66 | inflight "^1.0.4" 67 | inherits "2" 68 | minimatch "^3.0.4" 69 | once "^1.3.0" 70 | path-is-absolute "^1.0.0" 71 | 72 | growl@1.10.3: 73 | version "1.10.3" 74 | resolved "https://registry.yarnpkg.com/growl/-/growl-1.10.3.tgz#1926ba90cf3edfe2adb4927f5880bc22c66c790f" 75 | 76 | has-flag@^2.0.0: 77 | version "2.0.0" 78 | resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-2.0.0.tgz#e8207af1cc7b30d446cc70b734b5e8be18f88d51" 79 | 80 | he@1.1.1: 81 | version "1.1.1" 82 | resolved "https://registry.yarnpkg.com/he/-/he-1.1.1.tgz#93410fd21b009735151f8868c2f271f3427e23fd" 83 | 84 | inflight@^1.0.4: 85 | version "1.0.6" 86 | resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" 87 | dependencies: 88 | once "^1.3.0" 89 | wrappy "1" 90 | 91 | inherits@2: 92 | version "2.0.3" 93 | resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" 94 | 95 | is-windows@^1.0.0: 96 | version "1.0.1" 97 | resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-1.0.1.tgz#310db70f742d259a16a369202b51af84233310d9" 98 | 99 | isexe@^2.0.0: 100 | version "2.0.0" 101 | resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" 102 | 103 | lru-cache@^4.0.1: 104 | version "4.1.1" 105 | resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.1.tgz#622e32e82488b49279114a4f9ecf45e7cd6bba55" 106 | dependencies: 107 | pseudomap "^1.0.2" 108 | yallist "^2.1.2" 109 | 110 | minimatch@^3.0.4: 111 | version "3.0.4" 112 | resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" 113 | dependencies: 114 | brace-expansion "^1.1.7" 115 | 116 | minimist@0.0.8: 117 | version "0.0.8" 118 | resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d" 119 | 120 | mkdirp@0.5.1: 121 | version "0.5.1" 122 | resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903" 123 | dependencies: 124 | minimist "0.0.8" 125 | 126 | mocha@^4.0.1: 127 | version "4.0.1" 128 | resolved "https://registry.yarnpkg.com/mocha/-/mocha-4.0.1.tgz#0aee5a95cf69a4618820f5e51fa31717117daf1b" 129 | dependencies: 130 | browser-stdout "1.3.0" 131 | commander "2.11.0" 132 | debug "3.1.0" 133 | diff "3.3.1" 134 | escape-string-regexp "1.0.5" 135 | glob "7.1.2" 136 | growl "1.10.3" 137 | he "1.1.1" 138 | mkdirp "0.5.1" 139 | supports-color "4.4.0" 140 | 141 | ms@2.0.0: 142 | version "2.0.0" 143 | resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" 144 | 145 | once@^1.3.0: 146 | version "1.4.0" 147 | resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" 148 | dependencies: 149 | wrappy "1" 150 | 151 | path-is-absolute@^1.0.0: 152 | version "1.0.1" 153 | resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" 154 | 155 | pseudomap@^1.0.2: 156 | version "1.0.2" 157 | resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3" 158 | 159 | rimraf@^2.6.2: 160 | version "2.6.2" 161 | resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.2.tgz#2ed8150d24a16ea8651e6d6ef0f47c4158ce7a36" 162 | dependencies: 163 | glob "^7.0.5" 164 | 165 | shebang-command@^1.2.0: 166 | version "1.2.0" 167 | resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea" 168 | dependencies: 169 | shebang-regex "^1.0.0" 170 | 171 | shebang-regex@^1.0.0: 172 | version "1.0.0" 173 | resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3" 174 | 175 | supports-color@4.4.0: 176 | version "4.4.0" 177 | resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-4.4.0.tgz#883f7ddabc165142b2a61427f3352ded195d1a3e" 178 | dependencies: 179 | has-flag "^2.0.0" 180 | 181 | typescript@^2.6.2: 182 | version "2.6.2" 183 | resolved "https://registry.yarnpkg.com/typescript/-/typescript-2.6.2.tgz#3c5b6fd7f6de0914269027f03c0946758f7673a4" 184 | 185 | which@^1.2.9: 186 | version "1.3.0" 187 | resolved "https://registry.yarnpkg.com/which/-/which-1.3.0.tgz#ff04bdfc010ee547d780bec38e1ac1c2777d253a" 188 | dependencies: 189 | isexe "^2.0.0" 190 | 191 | wrappy@1: 192 | version "1.0.2" 193 | resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" 194 | 195 | yallist@^2.1.2: 196 | version "2.1.2" 197 | resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52" 198 | --------------------------------------------------------------------------------