├── .gitignore ├── LICENSE ├── README.md ├── demo ├── index.html ├── package.json ├── src │ ├── DataRenderer.jsx │ ├── MessageList.jsx │ └── index.jsx └── webpack.config.js ├── lib └── index.js ├── package.json ├── src └── index.js ├── test └── testCursor.js └── webpack.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | node_modules/ 3 | *.iml 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Martin Snyder 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # seamless-immutable-cursor 2 | Compact Cursor Library built on top of the excellent [seamless-immutable][seamless-immutable-github]. 3 | Cursors can be used to manage transitions and manipulations of immutable structures in an application. 4 | 5 | ## Presentation 6 | A [20 minute presentation](https://youtu.be/wQy5vxzNdV0) explains the utility of this library and the repository contents. [Slides can be viewed here](https://martinsnyder.net/presentations/revealjs/seamless-immutable-cursor.html) 7 | 8 | # Example 9 | ```javascript 10 | const rootCursor = new Cursor({ 11 | users: { 12 | abby: 1, 13 | ben: 2, 14 | claire: 3, 15 | dan: 4 16 | }, 17 | documents: [ 18 | { 19 | name: 'CV', 20 | owner: 1, 21 | mediaType: 'application/pdf' 22 | }, 23 | { 24 | name: 'References', 25 | owner: 1, 26 | mediaType: 'text/plain' 27 | } 28 | ] 29 | }); 30 | 31 | // Register a function to react to new generations of our immutable data 32 | rootCursor.onChange((nextData, prevData, pathUpdated) => { 33 | console.debug('Updated ' + JSON.stringify(pathUpdated)); 34 | }); 35 | 36 | // Create a cursor for a limited portion of our data hierarchy 37 | const childCursor = rootCursor.refine(['documents', 0, 'name']); 38 | 39 | // firstDocumentName will be 'CV' 40 | const firstDocumentName = childCursor.data; 41 | 42 | // Update -- this switches the data owned by rootCursor to point to 43 | // a new generation of immutable data 44 | childCursor.data = 'Resume'; 45 | 46 | // updatedFirstDocumentName will be 'Resume' because the cursor points 47 | // to the location, not the specific data 48 | const updatedFirstDocumentName = childCursor.data; 49 | 50 | // updatedFirstDocumentNameFromRoot will ALSO be 'Resume' because the 51 | // 'managed' data has moved to a new generation based on the prior update 52 | const updatedFirstDocumentNameFromRoot = rootCursor.data.documents[0].name; 53 | ``` 54 | 55 | # React Demo 56 | The demo folder contains a simple demo that combines this library, seamless-immutable and React. 57 | 58 | [seamless-immutable-github]: https://github.com/rtfeldman/seamless-immutable 59 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 26 | 27 | 28 | Demonstration of seamless-immutable-cursor with React.js 29 | 30 | 31 | 32 |
33 | 34 | 35 | -------------------------------------------------------------------------------- /demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "seamless-immutable-cursor-demo", 3 | "version": "0.3.0", 4 | "description": "Cursor Library add-on for seamless-immutable Demo", 5 | "main": "src/index.js", 6 | "author": "Martin Snyder", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/MartinSnyder/seamless-immutable-cursor.git" 10 | }, 11 | "license": "MIT", 12 | "scripts": { 13 | "start": "./node_modules/.bin/webpack-dev-server" 14 | }, 15 | "dependencies": { 16 | "react": "^16.8.1", 17 | "react-dom": "^16.8.1" 18 | }, 19 | "devDependencies": { 20 | "babel-cli": "^6.26.0", 21 | "babel-core": "^6.26.3", 22 | "babel-loader": "^7.1.5", 23 | "babel-preset-env": "^1.7.0", 24 | "babel-preset-react": "^6.24.1", 25 | "babel-preset-react-hmre": "^1.1.1", 26 | "chai": "^3.5.0", 27 | "mocha": "^3.1.2", 28 | "seamless-immutable": "^7.1.4", 29 | "webpack": "^4.29.3", 30 | "webpack-cli": "^3.2.3", 31 | "webpack-dev-server": "^3.1.14" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /demo/src/DataRenderer.jsx: -------------------------------------------------------------------------------- 1 | /* 2 | MIT License 3 | 4 | Copyright (c) 2016 Martin Snyder 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | */ 24 | import React from 'react'; 25 | 26 | // ES6 'function style' react component is a good choice for 'Pure' react components like this one 27 | const DataRenderer = ({data}) => { 28 | return
{JSON.stringify(data)}
; 29 | }; 30 | 31 | export default DataRenderer; 32 | -------------------------------------------------------------------------------- /demo/src/MessageList.jsx: -------------------------------------------------------------------------------- 1 | /* 2 | MIT License 3 | 4 | Copyright (c) 2016 Martin Snyder 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | */ 24 | import React from 'react'; 25 | 26 | // ES6 'function style' react component is a good choice for 'Pure' react components like this one 27 | const MessageList = ({messages}) => { 28 | // React components don't work when frozen, but seamless-immutable is aware of this and 29 | // will safely skip them when executing this map operation. 30 | let i = 0; 31 | const messageMarkup = messages.map(message => 32 |
{message}
33 | ); 34 | 35 | return
{messageMarkup}
; 36 | }; 37 | 38 | export default MessageList; 39 | -------------------------------------------------------------------------------- /demo/src/index.jsx: -------------------------------------------------------------------------------- 1 | /* 2 | MIT License 3 | 4 | Copyright (c) 2016 Martin Snyder 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | */ 24 | import React from 'react'; 25 | import ReactDOM from 'react-dom'; 26 | import Cursor from '../../src/index.js' 27 | import DataRenderer from './DataRenderer' 28 | import MessageList from './MessageList' 29 | 30 | // Create a root cursor 31 | const rootCursor = new Cursor({ 32 | users: { 33 | abby: 1, 34 | ben: 2, 35 | claire: 3, 36 | dan: 4 37 | }, 38 | documents: [ 39 | { 40 | name: 'CV', 41 | owner: 1, 42 | mediaType: 'application/pdf' 43 | }, 44 | { 45 | name: 'References', 46 | owner: 1, 47 | mediaType: 'text/plain' 48 | } 49 | ], 50 | messages: ['Initialized'] 51 | }); 52 | 53 | // Register a change handler that renders our page using react. Our React components 54 | // will only 'see' regular JavaScript objects that are runtime-immutable. Any attempts 55 | // by a React component to modify its properties will result in a runtime exception. 56 | rootCursor.onChange((nextData) => { 57 | ReactDOM.render( 58 |
59 | 60 | 61 |
, 62 | document.getElementById('mountPoint')); 63 | }); 64 | 65 | // TODO: Build your entire application around this concept! 66 | const startTime = new Date().getTime(); 67 | window.setInterval(() => { 68 | // Every second, we apply a change to a refined cursor. This creates a new generation of our 69 | // immutable data and triggers a render (via the onChange handler above) 70 | const messageCursor = rootCursor.refine('messages'); 71 | const currentMessages = messageCursor.data; 72 | messageCursor.data = currentMessages.concat('Pulse: ' + Math.round((new Date().getTime() - startTime) / 1000)); 73 | }, 1000); 74 | 75 | // For debugging, so you can access the application state in the browser console 76 | // NOTE: you can do this even when the debugger is not stopped at a breakpoint 77 | window.rootCursor = rootCursor; 78 | -------------------------------------------------------------------------------- /demo/webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | 3 | module.exports = { 4 | entry: './src/index.jsx', 5 | devtool: 'cheap-source-map', 6 | mode: 'development', 7 | output: { 8 | path: '/', 9 | publicPath: 'http://localhost:8080/', 10 | filename: 'bundle.js' 11 | }, 12 | resolve: { 13 | // Needed to require .jsx files without specifying the suffix 14 | // http://discuss.babeljs.io/t/es6-import-jsx-without-suffix/172/2 15 | extensions: ['.js', '.jsx'] 16 | }, 17 | module: { 18 | rules: [{ 19 | test: /\.m?js$/, 20 | exclude: /node_modules/, 21 | use: { 22 | loader: 'babel-loader', 23 | options: { 24 | presets: ['babel-preset-env'] 25 | } 26 | } 27 | }, 28 | { 29 | test: /.jsx?$/, 30 | loader: 'babel-loader', 31 | exclude: /node_modules/, 32 | query: { 33 | // Needed to handle 'npm link'ed modules 34 | // http://stackoverflow.com/questions/34574403/how-to-set-resolve-for-babel-loader-presets/ 35 | presets: ['babel-preset-env', 'babel-preset-react', 'babel-preset-react-hmre'].map(require.resolve) 36 | } 37 | }] 38 | } 39 | }; 40 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | 7 | var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }(); /* 8 | MIT License 9 | 10 | Copyright (c) 2016 Martin Snyder 11 | 12 | Permission is hereby granted, free of charge, to any person obtaining a copy 13 | of this software and associated documentation files (the "Software"), to deal 14 | in the Software without restriction, including without limitation the rights 15 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 16 | copies of the Software, and to permit persons to whom the Software is 17 | furnished to do so, subject to the following conditions: 18 | 19 | The above copyright notice and this permission notice shall be included in all 20 | copies or substantial portions of the Software. 21 | 22 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 23 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 24 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 25 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 26 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 27 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 28 | SOFTWARE. 29 | */ 30 | 31 | 32 | var _seamlessImmutable = require('seamless-immutable'); 33 | 34 | var _seamlessImmutable2 = _interopRequireDefault(_seamlessImmutable); 35 | 36 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 37 | 38 | function _possibleConstructorReturn(self, call) { if (!self) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return call && (typeof call === "object" || typeof call === "function") ? call : self; } 39 | 40 | function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function, not " + typeof superClass); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, enumerable: false, writable: true, configurable: true } }); if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass; } 41 | 42 | function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } 43 | 44 | /* 45 | * Custom getIn function 46 | * 47 | * Using custom getIn because Seamless Immutable's getIn function does not default to 48 | * the original object when path is empty. 49 | */ 50 | function getIn(obj, path) { 51 | var pointer = obj; 52 | var _iteratorNormalCompletion = true; 53 | var _didIteratorError = false; 54 | var _iteratorError = undefined; 55 | 56 | try { 57 | for (var _iterator = path[Symbol.iterator](), _step; !(_iteratorNormalCompletion = (_step = _iterator.next()).done); _iteratorNormalCompletion = true) { 58 | var el = _step.value; 59 | 60 | pointer = pointer ? pointer[el] : undefined; 61 | } 62 | } catch (err) { 63 | _didIteratorError = true; 64 | _iteratorError = err; 65 | } finally { 66 | try { 67 | if (!_iteratorNormalCompletion && _iterator.return) { 68 | _iterator.return(); 69 | } 70 | } finally { 71 | if (_didIteratorError) { 72 | throw _iteratorError; 73 | } 74 | } 75 | } 76 | 77 | return pointer; 78 | } 79 | 80 | /* 81 | * Cursor data that is private to the class/module. 82 | * 83 | * Instances of this class manage MUTABLE data associated with a cursor. This includes: 84 | * - The current generation of the immutable state object 85 | * - The current list of change listeners 86 | * 87 | * Because this class is private to the module and never returned to an outside caller, 88 | * its usage is known to us. It can ONLY be constructed by a root cursor and is shared 89 | * between the root cursor and any child cursors 'refined' from there. 90 | */ 91 | 92 | var PrivateData = function () { 93 | function PrivateData(initialData) { 94 | _classCallCheck(this, PrivateData); 95 | 96 | this.currentData = initialData; 97 | this.changeListeners = []; 98 | } 99 | 100 | /* 101 | * Updates the portion of this.currentData referenced by 'path' with the 'newValue' 102 | */ 103 | 104 | 105 | _createClass(PrivateData, [{ 106 | key: 'update', 107 | value: function update(path, newValue) { 108 | // this.currentData is about to become the "previous generation" 109 | var prevData = this.currentData; 110 | 111 | if (path.length === 0) { 112 | // Replace the data entirely. We must manually force its immutability when we do this. 113 | this.currentData = (0, _seamlessImmutable2.default)(newValue); 114 | } else { 115 | // Apply the update to produce the next generation. Because this.currentData has 116 | // been processed by seamless-immutable, nextData will automatically be immutable as well. 117 | this.currentData = this.currentData.setIn(path, newValue); 118 | } 119 | 120 | // Notify all change listeners 121 | var _iteratorNormalCompletion2 = true; 122 | var _didIteratorError2 = false; 123 | var _iteratorError2 = undefined; 124 | 125 | try { 126 | for (var _iterator2 = this.changeListeners[Symbol.iterator](), _step2; !(_iteratorNormalCompletion2 = (_step2 = _iterator2.next()).done); _iteratorNormalCompletion2 = true) { 127 | var changeListener = _step2.value; 128 | 129 | var shouldUpdate = true; 130 | var shorterPathLength = Math.min(path.length, changeListener.path.length); 131 | 132 | // Only update if the change listener path is a sub-path of the update path (or vice versa) 133 | for (var i = 1; i < shorterPathLength; i++) { 134 | shouldUpdate = shouldUpdate && path[i] === changeListener.path[i]; 135 | } 136 | 137 | if (shouldUpdate) { 138 | // Only call change listener if associated path data has changed 139 | if (getIn(this.currentData, changeListener.path) !== getIn(prevData, changeListener.path)) { 140 | // Pass nextData first because many listeners will ONLY care about that. 141 | changeListener(this.currentData, prevData, path); 142 | } 143 | } 144 | } 145 | } catch (err) { 146 | _didIteratorError2 = true; 147 | _iteratorError2 = err; 148 | } finally { 149 | try { 150 | if (!_iteratorNormalCompletion2 && _iterator2.return) { 151 | _iterator2.return(); 152 | } 153 | } finally { 154 | if (_didIteratorError2) { 155 | throw _iteratorError2; 156 | } 157 | } 158 | } 159 | } 160 | 161 | /* 162 | * Adds a new change listener to this managed data with the following signature: 163 | * function changeListener(nextRoot, prevRoot, pathUpdated) 164 | * 165 | * Where the parameters pass to this function have the following data types 166 | * nextRoot - Next generation of the JSON-style immutable data being managed by a cursor 167 | * prevRoot - Previous generation of the JSON-style immutable data being managed by a cursor 168 | * pathUpdated - Array of String indicating the keys used to navigate a nested/hierarchical 169 | * structure to the point where the update occurred. 170 | */ 171 | 172 | }, { 173 | key: 'addListener', 174 | value: function addListener(changeListener) { 175 | this.changeListeners.push(changeListener); 176 | } 177 | 178 | /* 179 | * Removes change listener 180 | */ 181 | 182 | }, { 183 | key: 'removeListener', 184 | value: function removeListener(changeListener) { 185 | this.changeListeners.splice(this.changeListeners.indexOf(changeListener), 1); 186 | } 187 | }]); 188 | 189 | return PrivateData; 190 | }(); 191 | 192 | /* 193 | * ES6 classes don't have a direct provision for private data, but we can associate data 194 | * with a class via a WeakMap and hide that WeakMap within the module. 195 | * 196 | * This WeakMap is of mapping of Cursor->PrivateData 197 | */ 198 | 199 | 200 | var privateDataMap = new WeakMap(); 201 | 202 | /* 203 | * Implementation of a cursor referencing an evolving immutable data structure. 204 | * 205 | * Note that callers of this module CAN receive instances of this class through 206 | * the normal usage pattern of constructing a 'RootCursor' object and then 207 | * calling 'refine', but they cannot construct them on their own. 208 | */ 209 | 210 | var Cursor = function () { 211 | /* 212 | * This class is private to the module, so its constructor is impossible to 213 | * invoke externally. This is good since the "privateData" parameter of the 214 | * constructor is not something we want external callers to attempt to provide. 215 | */ 216 | function Cursor(privateData, path) { 217 | _classCallCheck(this, Cursor); 218 | 219 | // Keep our private data hidden. This data is 'owned' by a RootCursor and 220 | // shared with all cursors 'refined' from that root (or 'refined' from a 221 | // child cursor of that root) 222 | privateDataMap.set(this, privateData); 223 | 224 | // Path will have already been locked by seamless-immutable 225 | this.path = path; 226 | 227 | // Freeze ourselves so that callers cannot re-assign the path post-construction 228 | Object.freeze(this); 229 | } 230 | 231 | /* 232 | * Property getter for 'data' property of a cursor. This returns the section of the 233 | * current generation of immutable data referred to by the path of the cursor. 234 | * 235 | * Calling this getter over time may return different results, but the data returned 236 | * is an immutable object that can be safely referenced without copy. 237 | * 238 | * This getter returns undefined in the case where the path specified by the cursor 239 | * does not exist in the current generation of the managed data. 240 | */ 241 | 242 | 243 | _createClass(Cursor, [{ 244 | key: 'refine', 245 | 246 | 247 | /* 248 | * Create a new child cursor from this cursor with the subPath appended to our current path 249 | */ 250 | value: function refine(subPath) { 251 | if (subPath.length === 0) { 252 | return this; 253 | } else { 254 | // Because this.path is already immutable, this.path.concat returns 255 | // a new immutable array. 256 | return new Cursor(privateDataMap.get(this), this.path.concat(subPath)); 257 | } 258 | } 259 | 260 | /* 261 | * Adds a new change listener to this cursor with the following signature: 262 | * function changeListener(nextRoot, prevRoot, pathUpdated) 263 | * 264 | * Where the parameters pass to this function have the following data types 265 | * nextRoot - Next generation of the JSON-style immutable data being managed by a cursor 266 | * prevRoot - Previous generation of the JSON-style immutable data being managed by a cursor 267 | * pathUpdated - Array of String indicating the keys used to navigate a nested/hierarchical 268 | * structure to the point where the update occurred. 269 | */ 270 | 271 | }, { 272 | key: 'onChange', 273 | value: function onChange(changeListener) { 274 | changeListener.path = this.path; 275 | privateDataMap.get(this).addListener(changeListener, this.path); 276 | } 277 | 278 | /* 279 | * Removes change listener 280 | */ 281 | 282 | }, { 283 | key: 'removeListener', 284 | value: function removeListener(changeListener) { 285 | privateDataMap.get(this).removeListener(changeListener); 286 | } 287 | }, { 288 | key: 'data', 289 | get: function get() { 290 | return getIn(privateDataMap.get(this).currentData, this.path); 291 | } 292 | 293 | /* 294 | * Property setter for 'data' property of a cursor. This creates a new generation 295 | * of the managed data object with the provided 'newValue' replacing whatever 296 | * exists in the 'path' of the current generation 297 | * 298 | * No attempt is made to address issues such as stale writes. Concurrency issues 299 | * are the responsibility of caller. 300 | */ 301 | , 302 | set: function set(newValue) { 303 | privateDataMap.get(this).update(this.path, newValue); 304 | } 305 | }]); 306 | 307 | return Cursor; 308 | }(); 309 | 310 | /* 311 | * Public entry into this module. 312 | * 313 | * RootCursor objects are the same as regular cursor objects except that: 314 | * 1. The 'root' cursor can be constructed by external callers 315 | * 2. The 'root' cursor can register changeListeners 316 | */ 317 | 318 | 319 | var RootCursor = function (_Cursor) { 320 | _inherits(RootCursor, _Cursor); 321 | 322 | function RootCursor() { 323 | var initialRoot = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; 324 | 325 | _classCallCheck(this, RootCursor); 326 | 327 | // Use seamless-immutable to constrain the initial data. This is the only 328 | // place where we invoke seamless-immutable because once we do this, our 329 | // interactions with these objects will only spawn other immutable objects 330 | return _possibleConstructorReturn(this, (RootCursor.__proto__ || Object.getPrototypeOf(RootCursor)).call(this, new PrivateData((0, _seamlessImmutable2.default)(initialRoot)), (0, _seamlessImmutable2.default)([]))); 331 | } 332 | 333 | return RootCursor; 334 | }(Cursor); 335 | 336 | exports.default = RootCursor; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "seamless-immutable-cursor", 3 | "version": "0.3.0", 4 | "description": "Cursor Library add-on for seamless-immutable", 5 | "main": "lib/index.js", 6 | "author": "Martin Snyder", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/MartinSnyder/seamless-immutable-cursor.git" 10 | }, 11 | "license": "MIT", 12 | "scripts": { 13 | "prepublish": "./node_modules/.bin/babel src --out-dir lib", 14 | "test": "./node_modules/.bin/mocha --compilers js:babel-core/register", 15 | "start": "./node_modules/.bin/webpack-dev-server --hot --inline" 16 | }, 17 | "babel": { 18 | "presets": [ 19 | "env" 20 | ] 21 | }, 22 | "dependencies": { 23 | "seamless-immutable": "^7.1.4" 24 | }, 25 | "devDependencies": { 26 | "babel-cli": "^6.26.0", 27 | "babel-core": "^6.26.3", 28 | "babel-loader": "^7.1.5", 29 | "babel-preset-env": "^1.7.0", 30 | "chai": "^3.5.0", 31 | "mocha": "^3.1.2", 32 | "webpack": "^4.29.3", 33 | "webpack-cli": "^3.2.3", 34 | "webpack-dev-server": "^3.1.14" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /* 2 | MIT License 3 | 4 | Copyright (c) 2016 Martin Snyder 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | */ 24 | import Immutable from 'seamless-immutable'; 25 | 26 | /* 27 | * Custom getIn function 28 | * 29 | * Using custom getIn because Seamless Immutable's getIn function does not default to 30 | * the original object when path is empty. 31 | */ 32 | function getIn(obj, path) { 33 | let pointer = obj; 34 | for (let el of path) { 35 | pointer = pointer 36 | ? pointer[el] 37 | : undefined; 38 | } 39 | 40 | return pointer; 41 | } 42 | 43 | /* 44 | * Cursor data that is private to the class/module. 45 | * 46 | * Instances of this class manage MUTABLE data associated with a cursor. This includes: 47 | * - The current generation of the immutable state object 48 | * - The current list of change listeners 49 | * 50 | * Because this class is private to the module and never returned to an outside caller, 51 | * its usage is known to us. It can ONLY be constructed by a root cursor and is shared 52 | * between the root cursor and any child cursors 'refined' from there. 53 | */ 54 | class PrivateData { 55 | constructor(initialData) { 56 | this.currentData = initialData; 57 | this.changeListeners = []; 58 | } 59 | 60 | /* 61 | * Updates the portion of this.currentData referenced by 'path' with the 'newValue' 62 | */ 63 | update(path, newValue) { 64 | // this.currentData is about to become the "previous generation" 65 | const prevData = this.currentData; 66 | 67 | if (path.length === 0) { 68 | // Replace the data entirely. We must manually force its immutability when we do this. 69 | this.currentData = Immutable(newValue); 70 | } 71 | else { 72 | // Apply the update to produce the next generation. Because this.currentData has 73 | // been processed by seamless-immutable, nextData will automatically be immutable as well. 74 | this.currentData = this.currentData.setIn(path, newValue); 75 | } 76 | 77 | // Notify all change listeners 78 | for (let changeListener of this.changeListeners) { 79 | let shouldUpdate = true; 80 | let shorterPathLength = Math.min(path.length, changeListener.path.length); 81 | 82 | // Only update if the change listener path is a sub-path of the update path (or vice versa) 83 | for(let i = 1; i < shorterPathLength; i++) { 84 | shouldUpdate = shouldUpdate && (path[i] === changeListener.path[i]) 85 | } 86 | 87 | if(shouldUpdate) { 88 | // Only call change listener if associated path data has changed 89 | if(getIn(this.currentData, changeListener.path) !== getIn(prevData, changeListener.path)) { 90 | // Pass nextData first because many listeners will ONLY care about that. 91 | changeListener(this.currentData, prevData, path); 92 | } 93 | } 94 | } 95 | } 96 | 97 | /* 98 | * Adds a new change listener to this managed data with the following signature: 99 | * function changeListener(nextRoot, prevRoot, pathUpdated) 100 | * 101 | * Where the parameters pass to this function have the following data types 102 | * nextRoot - Next generation of the JSON-style immutable data being managed by a cursor 103 | * prevRoot - Previous generation of the JSON-style immutable data being managed by a cursor 104 | * pathUpdated - Array of String indicating the keys used to navigate a nested/hierarchical 105 | * structure to the point where the update occurred. 106 | */ 107 | addListener(changeListener) { 108 | this.changeListeners.push(changeListener); 109 | } 110 | 111 | /* 112 | * Removes change listener 113 | */ 114 | removeListener(changeListener) { 115 | this.changeListeners.splice(this.changeListeners.indexOf(changeListener), 1) 116 | } 117 | } 118 | 119 | /* 120 | * ES6 classes don't have a direct provision for private data, but we can associate data 121 | * with a class via a WeakMap and hide that WeakMap within the module. 122 | * 123 | * This WeakMap is of mapping of Cursor->PrivateData 124 | */ 125 | const privateDataMap = new WeakMap(); 126 | 127 | /* 128 | * Implementation of a cursor referencing an evolving immutable data structure. 129 | * 130 | * Note that callers of this module CAN receive instances of this class through 131 | * the normal usage pattern of constructing a 'RootCursor' object and then 132 | * calling 'refine', but they cannot construct them on their own. 133 | */ 134 | class Cursor { 135 | /* 136 | * This class is private to the module, so its constructor is impossible to 137 | * invoke externally. This is good since the "privateData" parameter of the 138 | * constructor is not something we want external callers to attempt to provide. 139 | */ 140 | constructor(privateData, path) { 141 | // Keep our private data hidden. This data is 'owned' by a RootCursor and 142 | // shared with all cursors 'refined' from that root (or 'refined' from a 143 | // child cursor of that root) 144 | privateDataMap.set(this, privateData); 145 | 146 | // Path will have already been locked by seamless-immutable 147 | this.path = path; 148 | 149 | // Freeze ourselves so that callers cannot re-assign the path post-construction 150 | Object.freeze(this); 151 | } 152 | 153 | /* 154 | * Property getter for 'data' property of a cursor. This returns the section of the 155 | * current generation of immutable data referred to by the path of the cursor. 156 | * 157 | * Calling this getter over time may return different results, but the data returned 158 | * is an immutable object that can be safely referenced without copy. 159 | * 160 | * This getter returns undefined in the case where the path specified by the cursor 161 | * does not exist in the current generation of the managed data. 162 | */ 163 | get data() { 164 | return getIn(privateDataMap.get(this).currentData, this.path); 165 | } 166 | 167 | /* 168 | * Property setter for 'data' property of a cursor. This creates a new generation 169 | * of the managed data object with the provided 'newValue' replacing whatever 170 | * exists in the 'path' of the current generation 171 | * 172 | * No attempt is made to address issues such as stale writes. Concurrency issues 173 | * are the responsibility of caller. 174 | */ 175 | set data(newValue) { 176 | privateDataMap.get(this).update(this.path, newValue); 177 | } 178 | 179 | /* 180 | * Create a new child cursor from this cursor with the subPath appended to our current path 181 | */ 182 | refine(subPath) { 183 | if (subPath.length === 0) { 184 | return this; 185 | } 186 | else { 187 | // Because this.path is already immutable, this.path.concat returns 188 | // a new immutable array. 189 | return new Cursor(privateDataMap.get(this), this.path.concat(subPath)); 190 | } 191 | } 192 | 193 | /* 194 | * Adds a new change listener to this cursor with the following signature: 195 | * function changeListener(nextRoot, prevRoot, pathUpdated) 196 | * 197 | * Where the parameters pass to this function have the following data types 198 | * nextRoot - Next generation of the JSON-style immutable data being managed by a cursor 199 | * prevRoot - Previous generation of the JSON-style immutable data being managed by a cursor 200 | * pathUpdated - Array of String indicating the keys used to navigate a nested/hierarchical 201 | * structure to the point where the update occurred. 202 | */ 203 | onChange(changeListener) { 204 | changeListener.path = this.path; 205 | privateDataMap.get(this).addListener(changeListener, this.path); 206 | } 207 | 208 | /* 209 | * Removes change listener 210 | */ 211 | removeListener(changeListener) { 212 | privateDataMap.get(this).removeListener(changeListener); 213 | } 214 | } 215 | 216 | /* 217 | * Public entry into this module. 218 | * 219 | * RootCursor objects are the same as regular cursor objects except that: 220 | * 1. The 'root' cursor can be constructed by external callers 221 | * 2. The 'root' cursor can register changeListeners 222 | */ 223 | export default class RootCursor extends Cursor { 224 | constructor(initialRoot = {}) { 225 | // Use seamless-immutable to constrain the initial data. This is the only 226 | // place where we invoke seamless-immutable because once we do this, our 227 | // interactions with these objects will only spawn other immutable objects 228 | super(new PrivateData(Immutable(initialRoot)), Immutable([])); 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /test/testCursor.js: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | import Cursor from '../src/index'; 3 | 4 | const assert = chai.assert; 5 | 6 | describe('Data object with primitive', () => { 7 | const cursor = new Cursor('primitive'); 8 | it('constructs cleanly', () => assert.equal('primitive', cursor.data)); 9 | }); 10 | 11 | describe('Data object with structure', () => { 12 | const cursor = new Cursor({ 13 | attr: 'structured' 14 | }); 15 | it('constructs cleanly', () => assert.equal('structured', cursor.data.attr)); 16 | it('exposes root attribute immutably', () => chai.expect(() => cursor.data.attr = 'updated').to.throw()); 17 | }); 18 | 19 | describe('Multiple Data classes', () => { 20 | const one = new Cursor('one'); 21 | const two = new Cursor('two'); 22 | 23 | it('Maintain their data independently (WeakMap test)', () => { 24 | assert.equal('one', one.data); 25 | assert.equal('two', two.data); 26 | assert.notEqual(one.data, two.data); 27 | }); 28 | }); 29 | 30 | describe('Root Cursors', () => { 31 | const root = new Cursor({ 32 | interior: null 33 | }); 34 | 35 | it('Allows replacement of data', () => { 36 | root.data = { 37 | interior: 5 38 | }; 39 | }); 40 | 41 | it('Replaces data with immutable objects', () => { 42 | root.data = { 43 | interior: 5 44 | }; 45 | 46 | chai.expect(() => cursor.data.interior = 6).to.throw(); 47 | }); 48 | }); 49 | 50 | describe('Cursors', () => { 51 | const nested = new Cursor({ 52 | top: { 53 | middle: { 54 | bottom: 'nestedValue' 55 | } 56 | } 57 | }); 58 | 59 | let changes = []; 60 | const changeHandler = (nextRoot, prevRoot, pathUpdated) => { 61 | changes.push({ 62 | prevRoot: prevRoot, 63 | nextRoot: nextRoot, 64 | pathUpdated: pathUpdated 65 | }); 66 | } 67 | 68 | nested.onChange(changeHandler); 69 | 70 | it('Descends structures correctly', () => assert.equal('nestedValue', nested.refine(['top', 'middle', 'bottom']).data)); 71 | it('Safely returns if the path does not exist', () => assert.equal(undefined, nested.refine(['one', 'two', 'three']).data)); 72 | it('Can be refined to produce new cursors', () => assert.equal('nestedValue', nested.refine(['top']).refine(['middle', 'bottom']).data)); 73 | it('Can be used to update the managed data object', () => { 74 | const cursor = nested.refine(['top', 'middle', 'bottom']); 75 | cursor.data = 'updated'; 76 | 77 | assert.equal('updated', cursor.data); 78 | }); 79 | it('Can be used to add to the data object', () => { 80 | const cursor = nested.refine(['one', 'two', 'three']); 81 | cursor.data = 'added'; 82 | 83 | assert.equal('added', cursor.data); 84 | }); 85 | it('Makes added elements immutable as well', () => chai.expect(() => nested.root.one.two.three = 'updated').to.throw()); 86 | it('Fires change events correctly', () => { 87 | assert.equal(2, changes.length); 88 | 89 | // Verify first change 90 | assert.equal('nestedValue', changes[0].prevRoot.top.middle.bottom); 91 | assert.equal('updated', changes[0].nextRoot.top.middle.bottom); 92 | assert.deepEqual(['top', 'middle', 'bottom'], changes[0].pathUpdated); 93 | 94 | // Verify Second change 95 | assert.equal(undefined, changes[1].prevRoot.one); 96 | assert.equal('added', changes[1].nextRoot.one.two.three); 97 | assert.deepEqual(['one', 'two', 'three'], changes[1].pathUpdated); 98 | }); 99 | it('removes change listeners correctly', () => { 100 | nested.removeListener(changeHandler); 101 | 102 | const cursor = nested.refine(['top', 'middle', 'bottom']); 103 | cursor.data = 'second update'; 104 | 105 | // Verify change does not happen 106 | assert.equal(2, changes.length); 107 | }); 108 | it('Handles listeners on subcursors correctly', () => { 109 | const cursor1 = nested.refine(['top', 'middle', 'bottom']); 110 | const cursor2 = nested.refine(['one', 'two', 'three']); 111 | 112 | cursor1.onChange(changeHandler); 113 | cursor1.data = "third update"; 114 | cursor2.data = "updated"; 115 | 116 | // Verify cursor1 called changeHandler... 117 | assert.equal(3, changes.length); 118 | assert.equal('second update', changes[2].prevRoot.top.middle.bottom); 119 | assert.equal('third update', changes[2].nextRoot.top.middle.bottom); 120 | assert.deepEqual(['top', 'middle', 'bottom'], changes[2].pathUpdated); 121 | 122 | // ... but cursor2 did not 123 | assert.equal(3, changes.length); 124 | 125 | }) 126 | }); 127 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | 3 | module.exports = { 4 | entry: './src/index.js', 5 | devtool: 'cheap-source-map', 6 | output: { 7 | path: '/', 8 | publicPath: 'http://localhost:8080/', 9 | filename: 'bundle.js' 10 | }, 11 | module: { 12 | rules: [{ 13 | test: /\.m?js$/, 14 | exclude: /node_modules/, 15 | use: { 16 | loader: 'babel-loader', 17 | options: { 18 | presets: ['babel-preset-env'] 19 | } 20 | } 21 | }] 22 | } 23 | }; 24 | --------------------------------------------------------------------------------