├── .editorconfig ├── .eslintrc.js ├── .github └── workflows │ ├── pr-title.yml │ └── test.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.js ├── .yarn └── releases │ └── yarn-4.6.0.cjs ├── .yarnrc.yml ├── LICENSE ├── package.json ├── packages └── immer-yjs │ ├── CHANGELOG.md │ ├── package.json │ ├── readme.md │ ├── src │ ├── immer-yjs.test.ts │ ├── immer-yjs.ts │ ├── index.ts │ ├── sample-data.ts │ ├── types.ts │ └── util.ts │ ├── tsconfig.json │ └── vite.config.ts ├── readme.md └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 4 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | end_of_line = lf 10 | max_line_length = 120 11 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | parserOptions: { 5 | ecmaVersion: 2022, 6 | sourceType: 'module', 7 | ecmaFeatures: { 8 | jsx: true, 9 | }, 10 | }, 11 | env: { 12 | browser: true, 13 | amd: true, 14 | node: true, 15 | }, 16 | extends: ['eslint:recommended', 'prettier'], 17 | plugins: ['simple-import-sort', 'prettier'], 18 | rules: { 19 | 'prettier/prettier': ['error', {}, { usePrettierrc: true }], 20 | '@typescript-eslint/explicit-function-return-type': 'off', 21 | 'simple-import-sort/imports': 'error', 22 | 'simple-import-sort/exports': 'error', 23 | 'no-unused-vars': 'off', 24 | }, 25 | } 26 | -------------------------------------------------------------------------------- /.github/workflows/pr-title.yml: -------------------------------------------------------------------------------- 1 | on: 2 | pull_request: 3 | types: [opened, edited, synchronize, reopened] 4 | 5 | permissions: 6 | pull-requests: read 7 | 8 | # https://github.com/amannn/action-semantic-pull-request 9 | jobs: 10 | title-check: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: amannn/action-semantic-pull-request@v5 14 | env: 15 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 16 | with: 17 | requireScope: false 18 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: 'Code Integration Checks' 2 | on: ['push'] 3 | jobs: 4 | integration-checks: 5 | runs-on: ubuntu-latest 6 | name: Code Integration Checks 7 | steps: 8 | - name: Check out 9 | uses: actions/checkout@v3 10 | - name: Set up node 11 | uses: actions/setup-node@v3 12 | with: 13 | node-version: 22 14 | cache: 'yarn' 15 | registry-url: 'https://registry.npmjs.org' 16 | - name: Install 17 | run: yarn install --immutable 18 | - name: Check Code Formatting 19 | if: always() 20 | run: yarn check:formatting 21 | - name: Check Types 22 | if: always() 23 | run: yarn check:types 24 | - name: Lint 25 | if: always() 26 | run: yarn lint 27 | - name: Test 28 | if: always() 29 | run: yarn test 30 | - name: Build 31 | if: always() 32 | run: yarn build 33 | # TODO test build output: https://github.com/sep2/immer-yjs/issues/18 34 | # - name: Test Build Output 35 | # if: always() 36 | # run: yarn workspace test-app test 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | dist/ 3 | node_modules/ 4 | coverage/ 5 | 6 | # Logs 7 | logs 8 | *.log 9 | npm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | 14 | # OS 15 | .DS_Store 16 | 17 | # Tests 18 | /coverage 19 | /.nyc_output 20 | 21 | # IDEs and editors 22 | /.idea 23 | .project 24 | .classpath 25 | .c9/ 26 | *.launch 27 | .settings/ 28 | *.sublime-workspace 29 | 30 | # IDE - VSCode 31 | .vscode 32 | #.vscode/* 33 | #!.vscode/settings.json 34 | #!.vscode/tasks.json 35 | #!.vscode/launch.json 36 | #!.vscode/extensions.json 37 | 38 | # Environment 39 | .env 40 | *.env 41 | 42 | # If you're not using Zero-Installs: 43 | .yarn/* 44 | !.yarn/patches 45 | !.yarn/releases 46 | !.yarn/plugins 47 | !.yarn/sdks 48 | !.yarn/versions 49 | .pnp.* 50 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .pnp.cjs 2 | .pnp.loader.mjs 3 | .yarn 4 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 120, 3 | singleQuote: true, 4 | trailingComma: 'es5', 5 | bracketSpacing: true, 6 | semi: false, 7 | tabWidth: 4, 8 | endOfLine: 'lf', 9 | } 10 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | compressionLevel: mixed 2 | 3 | enableGlobalCache: false 4 | 5 | nodeLinker: pnp 6 | 7 | packageExtensions: 8 | '@typescript-eslint/parser@*': 9 | dependencies: 10 | typescript: '*' 11 | '@typescript-eslint/typescript-estree@*': 12 | dependencies: 13 | typescript: '*' 14 | eslint-module-utils@*: 15 | dependencies: 16 | typescript: '*' 17 | 18 | yarnPath: .yarn/releases/yarn-4.6.0.cjs 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 sep2 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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "immer-yjs-monorepo", 3 | "private": true, 4 | "license": "MIT", 5 | "workspaces": [ 6 | "packages/*" 7 | ], 8 | "scripts": { 9 | "build": "yarn workspace immer-yjs build", 10 | "test": "yarn workspace immer-yjs test", 11 | "publish": "yarn workspace immer-yjs npm publish", 12 | "release": "yarn workspace immer-yjs release", 13 | "lint": "eslint --ext .js,.ts --ignore-path .gitignore --fix packages", 14 | "format": "prettier . --write", 15 | "check:formatting": "prettier . --check", 16 | "check:types": "yarn workspace immer-yjs check:types" 17 | }, 18 | "devDependencies": { 19 | "@types/node": "^17.0.21", 20 | "@typescript-eslint/eslint-plugin": "^5.13.0", 21 | "@typescript-eslint/parser": "^5.13.0", 22 | "eslint": "^8.10.0", 23 | "eslint-config-prettier": "^8.4.0", 24 | "eslint-plugin-import": "^2.25.4", 25 | "eslint-plugin-prettier": "^5.2.3", 26 | "eslint-plugin-simple-import-sort": "^7.0.0", 27 | "prettier": "^3.4.2", 28 | "vite": "^2.8.4" 29 | }, 30 | "packageManager": "yarn@4.6.0" 31 | } 32 | -------------------------------------------------------------------------------- /packages/immer-yjs/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ## 1.1.0 (2022-11-11) 6 | 7 | ### ⚠ BREAKING CHANGES 8 | 9 | - update deps to latest 10 | 11 | ### Features 12 | 13 | - add option to configure applyPatch ([41cb3e8](https://github.com/sep2/immer-yjs/commit/41cb3e8dd316bbfd19045aba2590ccb331be523d)) 14 | - export default bind ([e54e3cc](https://github.com/sep2/immer-yjs/commit/e54e3cca0a8df6971fbe821be00af3440bcd5b9e)) 15 | 16 | ### Bug Fixes 17 | 18 | - bundle badge ([a1c77dd](https://github.com/sep2/immer-yjs/commit/a1c77dded078b33e8f0c1507847052b21589b59d)) 19 | - handle replace array.length operation ([e4fbe7a](https://github.com/sep2/immer-yjs/commit/e4fbe7a17f311a7d885ed7e4a67ce854c8dafd1e)) 20 | - support mutating root type ([0a42efe](https://github.com/sep2/immer-yjs/commit/0a42efed8c2249d640d9bbcf4279fe3d555d7560)) 21 | 22 | - update deps to latest ([d53c096](https://github.com/sep2/immer-yjs/commit/d53c0969cf459423648e7dc723eae5a7c7826d70)) 23 | 24 | ## 1.0.0 (2022-04-27) 25 | 26 | Bump to 1.0.0 because of this issue [standard-version#539](https://github.com/conventional-changelog/standard-version/issues/539) 27 | 28 | ### ⚠ BREAKING CHANGES 29 | 30 | - update deps to latest 31 | 32 | ### Features 33 | 34 | - add option to configure applyPatch ([41cb3e8](https://github.com/sep2/immer-yjs/commit/41cb3e8dd316bbfd19045aba2590ccb331be523d)) 35 | - export default bind ([e54e3cc](https://github.com/sep2/immer-yjs/commit/e54e3cca0a8df6971fbe821be00af3440bcd5b9e)) 36 | 37 | ### Bug Fixes 38 | 39 | - bundle badge ([a1c77dd](https://github.com/sep2/immer-yjs/commit/a1c77dded078b33e8f0c1507847052b21589b59d)) 40 | - support mutating root type ([0a42efe](https://github.com/sep2/immer-yjs/commit/0a42efed8c2249d640d9bbcf4279fe3d555d7560)) 41 | 42 | - update deps to latest ([d53c096](https://github.com/sep2/immer-yjs/commit/d53c0969cf459423648e7dc723eae5a7c7826d70)) 43 | 44 | ### [0.1.4](https://github.com/sep2/immer-yjs/compare/v0.1.3...v0.1.4) (2022-03-17) 45 | 46 | ### Bug Fixes 47 | 48 | - bundle badge ([a1c77dd](https://github.com/sep2/immer-yjs/commit/a1c77dded078b33e8f0c1507847052b21589b59d)) 49 | 50 | ### 0.1.3 (2022-03-08) 51 | 52 | ### Features 53 | 54 | - add option to configure applyPatch ([41cb3e8](https://github.com/sep2/immer-yjs/commit/41cb3e8dd316bbfd19045aba2590ccb331be523d)) 55 | - export default bind ([e54e3cc](https://github.com/sep2/immer-yjs/commit/e54e3cca0a8df6971fbe821be00af3440bcd5b9e)) 56 | 57 | ### Bug Fixes 58 | 59 | - support mutating root type ([0a42efe](https://github.com/sep2/immer-yjs/commit/0a42efed8c2249d640d9bbcf4279fe3d555d7560)) 60 | 61 | ### 0.1.2 (2022-03-08) 62 | 63 | ### Features 64 | 65 | - add option to configure applyPatch ([d20b970](https://github.com/sep2/immer-yjs/commit/d20b970c4a75801230b3eb6094d290db62386e6d)) 66 | 67 | ### 0.0.9 (2022-03-04) 68 | 69 | ### Bug Fixes 70 | 71 | - support mutating root type ([0a42efe](https://github.com/sep2/immer-yjs/commit/0a42efed8c2249d640d9bbcf4279fe3d555d7560)) 72 | -------------------------------------------------------------------------------- /packages/immer-yjs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "immer-yjs", 3 | "license": "MIT", 4 | "description": "Combine immer & y.js", 5 | "version": "1.1.0", 6 | "Author": "LCZ", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/sep2/immer-yjs.git" 10 | }, 11 | "main": "./dist/immer-yjs.umd.js", 12 | "module": "./dist/immer-yjs.es.js", 13 | "types": "./dist/index.d.js", 14 | "exports": { 15 | ".": { 16 | "import": "./dist/immer-yjs.es.js", 17 | "require": "./dist/immer-yjs.umd.js", 18 | "types": "./dist/index.d.ts" 19 | } 20 | }, 21 | "files": [ 22 | "dist" 23 | ], 24 | "scripts": { 25 | "build": "tsc && vite build", 26 | "test": "NODE_NO_WARNINGS=1 vitest", 27 | "coverage": "vitest run --coverage", 28 | "copy-readme": "cp ../../README.md README.md", 29 | "release": "yarn copy-readme && standard-version", 30 | "check:types": "tsc --noEmit" 31 | }, 32 | "peerDependencies": { 33 | "immer": "^10.1.1", 34 | "yjs": "^13.5.35" 35 | }, 36 | "devDependencies": { 37 | "immer": "^10.1.1", 38 | "standard-version": "^9.3.2", 39 | "typescript": "^4.6.3", 40 | "vite": "^2.9.6", 41 | "vite-plugin-dts": "^3.9.1", 42 | "vitest": "^0.10.0", 43 | "yjs": "^13.5.35" 44 | }, 45 | "keywords": [ 46 | "immer", 47 | "yjs", 48 | "crdt" 49 | ] 50 | } 51 | -------------------------------------------------------------------------------- /packages/immer-yjs/readme.md: -------------------------------------------------------------------------------- 1 | # immer-yjs 2 | 3 | [![npm](https://img.shields.io/npm/v/immer-yjs.svg)](https://www.npmjs.com/package/immer-yjs) 4 | [![size](https://img.shields.io/bundlephobia/minzip/immer-yjs)](https://bundlephobia.com/result?p=immer-yjs) 5 | 6 | Combine immer & y.js 7 | 8 | [Documentation](https://github.com/sep2/immer-yjs#readme) 9 | -------------------------------------------------------------------------------- /packages/immer-yjs/src/immer-yjs.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest' 2 | import * as Y from 'yjs' 3 | 4 | import { bind } from './immer-yjs' 5 | import { createSampleObject, id1, id2, id3 } from './sample-data' 6 | 7 | test('bind usage demo', () => { 8 | const doc = new Y.Doc() 9 | 10 | const initialObj = createSampleObject() // plain object 11 | 12 | const topLevelMap = 'map' 13 | 14 | // get reference of CRDT type for binding 15 | const map = doc.getMap(topLevelMap) 16 | 17 | // bind the top-level CRDT type, works for Y.Array as well 18 | const binder = bind(map) 19 | 20 | // initialize document with sample data 21 | binder.update(() => { 22 | return initialObj 23 | }) 24 | 25 | // snapshot reference should not change if no update 26 | expect(binder.get()).toBe(binder.get()) 27 | 28 | // get current state as snapshot 29 | const snapshot1 = binder.get() 30 | 31 | // should equal to initial structurally 32 | expect(snapshot1).toStrictEqual(initialObj) 33 | 34 | // should equal to yjs structurally 35 | expect(snapshot1).toStrictEqual(map.toJSON()) 36 | 37 | // get the reference to be compared after changes are made 38 | const yd1 = map.get(id1) as any 39 | 40 | // nested objects / arrays are properly converted to Y.Maps / Y.Arrays 41 | expect(yd1).toBeInstanceOf(Y.Map) 42 | expect(yd1.get('batters')).toBeInstanceOf(Y.Map) 43 | expect(yd1.get('topping')).toBeInstanceOf(Y.Array) 44 | expect(yd1.get('batters').get('batter')).toBeInstanceOf(Y.Array) 45 | expect(yd1.get('batters').get('batter').get(0).get('id')).toBeTypeOf('string') 46 | 47 | // update the state with immer 48 | binder.update((state) => { 49 | state[id1].ppu += 0.1 50 | const d1 = state[id1] 51 | 52 | d1.topping.splice(2, 2, { id: '7777', type: 'test1' }, { id: '8888', type: 'test2' }) 53 | d1.topping.push({ id: '9999', type: 'test3' }) 54 | 55 | delete state[id3] 56 | }) 57 | 58 | // get snapshot after modified 59 | const snapshot2 = binder.get() 60 | 61 | // snapshot1 unchanged 62 | expect(snapshot1).toStrictEqual(initialObj) 63 | 64 | // snapshot2 changed 65 | expect(snapshot1).not.equal(snapshot2) 66 | 67 | // changed properties should reflect what we did in update(...) 68 | expect(snapshot2[id1].ppu).toStrictEqual(0.65) 69 | expect(snapshot2[id1].topping.find((x) => x.id === '9999')).toStrictEqual({ id: '9999', type: 'test3' }) 70 | expect(snapshot2[id3]).toBeUndefined() 71 | 72 | // reference changed as well 73 | expect(snapshot2[id1]).not.toBe(snapshot1[id1]) 74 | 75 | // unchanged properties should keep referential equality with previous snapshot 76 | expect(snapshot2[id2]).toBe(snapshot1[id2]) 77 | expect(snapshot2[id1].batters).toBe(snapshot1[id1].batters) 78 | expect(snapshot2[id1].topping[0]).toBe(snapshot1[id1].topping[0]) 79 | 80 | // the underlying yjs data type reflect changes as well 81 | expect(map.toJSON()).toStrictEqual(snapshot2) 82 | 83 | // but yjs data type should not change reference (they are mutated in-place whenever possible) 84 | expect(map).toBe(doc.getMap(topLevelMap)) 85 | expect(map.get(id1)).toBe(yd1) 86 | expect((map.get(id1) as any).get('topping')).toBe(yd1.get('topping')) 87 | 88 | // save the length for later comparison 89 | const expectLength = binder.get()[id1].batters.batter.length 90 | 91 | // change from y.js 92 | yd1.get('batters') 93 | .get('batter') 94 | .push([{ id: '1005', type: 'test' }]) 95 | 96 | // change reflected in snapshot 97 | expect(binder.get()[id1].batters.batter.at(-1)).toStrictEqual({ id: '1005', type: 'test' }) 98 | 99 | // now the length + 1 100 | expect(binder.get()[id1].batters.batter.length).toBe(expectLength + 1) 101 | 102 | // delete something from yjs 103 | yd1.delete('topping') 104 | 105 | // deletion reflected in snapshot 106 | expect(binder.get()[id1].topping).toBeUndefined() 107 | 108 | // release the observer, so the CRDT type can be bind again 109 | binder.unbind() 110 | }) 111 | 112 | test('boolean in array', () => { 113 | const doc = new Y.Doc() 114 | 115 | const map = doc.getMap('data') 116 | 117 | const binder = bind(map) 118 | 119 | binder.update((state) => { 120 | state.k1 = true 121 | state.k2 = false 122 | state.k3 = [true, false, true] 123 | }) 124 | 125 | expect(map.toJSON()).toStrictEqual({ k1: true, k2: false, k3: [true, false, true] }) 126 | }) 127 | 128 | test('customize applyPatch', () => { 129 | const doc = new Y.Doc() 130 | 131 | const map = doc.getMap('data') 132 | 133 | const initialObj = createSampleObject() // plain object 134 | 135 | const binder = bind(map, { 136 | applyPatch: (target, patch, applyPatch) => { 137 | // you can inspect the patch.path and decide what to do with target 138 | // optionally delegate to the default patch handler 139 | // (modify target/patch before delegating as you want) 140 | applyPatch(target, patch) 141 | // can also postprocessing after the default behavior is applied 142 | }, 143 | }) 144 | 145 | binder.update(() => initialObj) 146 | 147 | expect(binder.get()).toStrictEqual(initialObj) 148 | 149 | expect(binder.get()).toStrictEqual(map.toJSON()) 150 | 151 | expect(binder.get()).toBe(binder.get()) 152 | }) 153 | 154 | describe('array splice', () => { 155 | function prepareArrayDoc(...items: number[]) { 156 | const doc = new Y.Doc() 157 | const binder = bind<{ array: number[] }>(doc.getMap('data'), { 158 | applyPatch: (target, patch, apply) => { 159 | apply(target, patch) 160 | }, 161 | }) 162 | binder.update((data) => { 163 | data.array = items 164 | }) 165 | return { doc, binder } 166 | } 167 | 168 | test('remove nonexistent item', () => { 169 | const { binder } = prepareArrayDoc() 170 | 171 | binder.update((data) => { 172 | data.array.splice(0, 1) 173 | }) 174 | 175 | expect(binder.get().array.length).toBe(0) 176 | }) 177 | 178 | test('remove single item', () => { 179 | const { binder } = prepareArrayDoc(1) 180 | 181 | binder.update((data) => { 182 | data.array.splice(0, 1) 183 | }) 184 | 185 | expect(binder.get().array.length).toBe(0) 186 | }) 187 | 188 | test('remove first item of many', () => { 189 | const { binder } = prepareArrayDoc(1, 2, 3) 190 | 191 | // results in ops 192 | // replace array[0] value 2 193 | // replace array[1] value 3 194 | // replace array.length value 2 195 | binder.update((data) => { 196 | data.array.splice(0, 1) 197 | }) 198 | 199 | expect(binder.get().array.length).toBe(2) 200 | }) 201 | 202 | test('remove last multiple items', () => { 203 | const { binder } = prepareArrayDoc(1, 2, 3, 4) 204 | 205 | binder.update((data) => { 206 | data.array.splice(2, 2) 207 | }) 208 | 209 | const result = binder.get().array 210 | expect(result.length).toBe(2) 211 | expect(result[0]).toBe(1) 212 | expect(result[1]).toBe(2) 213 | }) 214 | 215 | test('replace last multiple items', () => { 216 | const { binder } = prepareArrayDoc(1, 2, 3, 4) 217 | 218 | binder.update((data) => { 219 | data.array.splice(2, 2, 5, 6) 220 | }) 221 | 222 | const result = binder.get().array 223 | expect(result.length).toBe(4) 224 | expect(result[0]).toBe(1) 225 | expect(result[1]).toBe(2) 226 | expect(result[2]).toBe(5) 227 | expect(result[3]).toBe(6) 228 | }) 229 | 230 | test('remove first multiple items', () => { 231 | const { binder } = prepareArrayDoc(1, 2, 3, 4) 232 | 233 | binder.update((data) => { 234 | data.array.splice(0, 2) 235 | }) 236 | 237 | const result = binder.get().array 238 | expect(result.length).toBe(2) 239 | expect(result[0]).toBe(3) 240 | expect(result[1]).toBe(4) 241 | }) 242 | 243 | test('replace first multiple items', () => { 244 | const { binder } = prepareArrayDoc(1, 2, 3, 4) 245 | 246 | binder.update((data) => { 247 | data.array.splice(0, 2, 5, 6) 248 | }) 249 | 250 | const result = binder.get().array 251 | expect(result.length).toBe(4) 252 | expect(result[0]).toBe(5) 253 | expect(result[1]).toBe(6) 254 | expect(result[2]).toBe(3) 255 | expect(result[3]).toBe(4) 256 | }) 257 | }) 258 | -------------------------------------------------------------------------------- /packages/immer-yjs/src/immer-yjs.ts: -------------------------------------------------------------------------------- 1 | import { enablePatches, Patch, produce, produceWithPatches } from 'immer' 2 | import * as Y from 'yjs' 3 | 4 | import { JSONArray, JSONObject, JSONValue } from './types' 5 | import { isJSONArray, isJSONObject, notImplemented, toPlainValue, toYDataType } from './util' 6 | 7 | enablePatches() 8 | 9 | export type Snapshot = JSONObject | JSONArray 10 | 11 | function applyYEvent(base: T, event: Y.YEvent) { 12 | if (event instanceof Y.YMapEvent && isJSONObject(base)) { 13 | const source = event.target as Y.Map 14 | 15 | event.changes.keys.forEach((change, key) => { 16 | switch (change.action) { 17 | case 'add': 18 | case 'update': 19 | base[key] = toPlainValue(source.get(key)) 20 | break 21 | case 'delete': 22 | delete base[key] 23 | break 24 | } 25 | }) 26 | } else if (event instanceof Y.YArrayEvent && isJSONArray(base)) { 27 | const arr = base as unknown as any[] 28 | 29 | let retain = 0 30 | event.changes.delta.forEach((change) => { 31 | if (change.retain) { 32 | retain += change.retain 33 | } 34 | if (change.delete) { 35 | arr.splice(retain, change.delete) 36 | } 37 | if (change.insert) { 38 | if (Array.isArray(change.insert)) { 39 | arr.splice(retain, 0, ...change.insert.map(toPlainValue)) 40 | } else { 41 | arr.splice(retain, 0, toPlainValue(change.insert)) 42 | } 43 | retain += change.insert.length 44 | } 45 | }) 46 | } 47 | } 48 | 49 | function applyYEvents(snapshot: S, events: Y.YEvent[]) { 50 | return produce(snapshot, (target) => { 51 | for (const event of events) { 52 | const base = event.path.reduce((obj, step) => { 53 | // @ts-ignore 54 | return obj[step] 55 | }, target) 56 | 57 | applyYEvent(base, event) 58 | } 59 | }) 60 | } 61 | 62 | const PATCH_REPLACE = 'replace' 63 | const PATCH_ADD = 'add' 64 | const PATCH_REMOVE = 'remove' 65 | 66 | function defaultApplyPatch(target: Y.Map | Y.Array, patch: Patch) { 67 | const { path, op, value } = patch 68 | 69 | if (!path.length) { 70 | if (op !== PATCH_REPLACE) { 71 | notImplemented() 72 | } 73 | 74 | if (target instanceof Y.Map && isJSONObject(value)) { 75 | target.clear() 76 | for (const k in value) { 77 | target.set(k, toYDataType(value[k])) 78 | } 79 | } else if (target instanceof Y.Array && isJSONArray(value)) { 80 | target.delete(0, target.length) 81 | target.push(value.map(toYDataType)) 82 | } else { 83 | notImplemented() 84 | } 85 | 86 | return 87 | } 88 | 89 | let base = target 90 | for (let i = 0; i < path.length - 1; i++) { 91 | const step = path[i] 92 | base = base.get(step as never) 93 | } 94 | 95 | const property = path[path.length - 1] 96 | 97 | if (base instanceof Y.Map && typeof property === 'string') { 98 | switch (op) { 99 | case PATCH_ADD: 100 | case PATCH_REPLACE: 101 | base.set(property, toYDataType(value)) 102 | break 103 | case PATCH_REMOVE: 104 | base.delete(property) 105 | break 106 | } 107 | } else if (base instanceof Y.Array && typeof property === 'number') { 108 | switch (op) { 109 | case PATCH_ADD: 110 | base.insert(property, [toYDataType(value)]) 111 | break 112 | case PATCH_REPLACE: 113 | base.delete(property) 114 | base.insert(property, [toYDataType(value)]) 115 | break 116 | case PATCH_REMOVE: 117 | base.delete(property) 118 | break 119 | } 120 | } else if (base instanceof Y.Array && property === 'length') { 121 | if (value < base.length) { 122 | const diff = base.length - value 123 | base.delete(value, diff) 124 | } 125 | } else { 126 | notImplemented() 127 | } 128 | } 129 | 130 | export type UpdateFn = (draft: S) => void 131 | 132 | function applyUpdate( 133 | source: Y.Map | Y.Array, 134 | snapshot: S, 135 | fn: UpdateFn, 136 | applyPatch: typeof defaultApplyPatch 137 | ) { 138 | const [, patches] = produceWithPatches(snapshot, fn) 139 | for (const patch of patches) { 140 | applyPatch(source, patch) 141 | } 142 | } 143 | 144 | export type ListenerFn = (snapshot: S) => void 145 | export type UnsubscribeFn = () => void 146 | 147 | export type Binder = { 148 | /** 149 | * Release the binder. 150 | */ 151 | unbind: () => void 152 | 153 | /** 154 | * Return the latest snapshot. 155 | */ 156 | get: () => S 157 | 158 | /** 159 | * Update the snapshot as well as the corresponding y.js data. 160 | * Same usage as `produce` from `immer`. 161 | */ 162 | update: (fn: UpdateFn) => void 163 | 164 | /** 165 | * Subscribe to snapshot update, fired when: 166 | * 1. User called update(fn). 167 | * 2. y.js source.observeDeep() fired. 168 | */ 169 | subscribe: (fn: ListenerFn) => UnsubscribeFn 170 | } 171 | 172 | export type Options = { 173 | /** 174 | * Customize immer patch application. 175 | * Should apply patch to the target y.js data. 176 | * @param target The y.js data to be modified. 177 | * @param patch The patch that should be applied, please refer to 'immer' patch documentation. 178 | * @param applyPatch the default behavior to apply patch, call this to handle the normal case. 179 | */ 180 | applyPatch?: (target: Y.Map | Y.Array, patch: Patch, applyPatch: typeof defaultApplyPatch) => void 181 | } 182 | 183 | /** 184 | * Bind y.js data type. 185 | * @param source The y.js data type to bind. 186 | * @param options Change default behavior, can be omitted. 187 | */ 188 | export function bind(source: Y.Map | Y.Array, options?: Options): Binder { 189 | let snapshot = source.toJSON() as S 190 | 191 | const get = () => snapshot 192 | 193 | const subscription = new Set>() 194 | 195 | const subscribe = (fn: ListenerFn) => { 196 | subscription.add(fn) 197 | return () => void subscription.delete(fn) 198 | } 199 | 200 | const observer = (events: Y.YEvent[]) => { 201 | snapshot = applyYEvents(get(), events) 202 | subscription.forEach((fn) => fn(get())) 203 | } 204 | 205 | source.observeDeep(observer) 206 | const unbind = () => source.unobserveDeep(observer) 207 | 208 | const applyPatchInOption = options ? options.applyPatch : undefined 209 | 210 | const applyPatch = applyPatchInOption 211 | ? (target: Y.Map | Y.Array, patch: Patch) => applyPatchInOption(target, patch, defaultApplyPatch) 212 | : defaultApplyPatch 213 | 214 | const update = (fn: UpdateFn) => { 215 | const doc = source.doc 216 | 217 | const doApplyUpdate = () => { 218 | applyUpdate(source, get(), fn, applyPatch) 219 | } 220 | 221 | if (doc) { 222 | Y.transact(doc, doApplyUpdate) 223 | } else { 224 | doApplyUpdate() 225 | } 226 | } 227 | 228 | return { 229 | unbind, 230 | get, 231 | update, 232 | subscribe, 233 | } 234 | } 235 | -------------------------------------------------------------------------------- /packages/immer-yjs/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './immer-yjs' 2 | export * from './types' 3 | export { applyJsonArray, applyJsonObject } from './util' 4 | -------------------------------------------------------------------------------- /packages/immer-yjs/src/sample-data.ts: -------------------------------------------------------------------------------- 1 | // Copied from https://opensource.adobe.com/Spry/samples/data_region/JSONDataSetSample.html 2 | 3 | export const id1 = '0001' 4 | export const id2 = '0002' 5 | export const id3 = '0003' 6 | 7 | const data1 = { 8 | id: id1, 9 | type: 'donut', 10 | name: 'Cake', 11 | ppu: 0.55, 12 | batters: { 13 | batter: [ 14 | { id: '1001', type: 'Regular' }, 15 | { id: '1002', type: 'Chocolate' }, 16 | { id: '1003', type: 'Blueberry' }, 17 | { id: '1004', type: "Devil's Food" }, 18 | ], 19 | }, 20 | topping: [ 21 | { id: '5001', type: 'None' }, 22 | { id: '5002', type: 'Glazed' }, 23 | { id: '5005', type: 'Sugar' }, 24 | { id: '5007', type: 'Powdered Sugar' }, 25 | { id: '5006', type: 'Chocolate with Sprinkles' }, 26 | { id: '5003', type: 'Chocolate' }, 27 | { id: '5004', type: 'Maple' }, 28 | ], 29 | } 30 | 31 | const data2 = { 32 | id: id2, 33 | type: 'donut', 34 | name: 'Raised', 35 | ppu: 0.55, 36 | batters: { 37 | batter: [{ id: '1001', type: 'Regular' }], 38 | }, 39 | topping: [ 40 | { id: '5001', type: 'None' }, 41 | { id: '5002', type: 'Glazed' }, 42 | { id: '5005', type: 'Sugar' }, 43 | { id: '5003', type: 'Chocolate' }, 44 | { id: '5004', type: 'Maple' }, 45 | ], 46 | } 47 | 48 | const data3 = { 49 | id: id3, 50 | type: 'donut', 51 | name: 'Old Fashioned', 52 | ppu: 0.55, 53 | batters: { 54 | batter: [ 55 | { id: '1001', type: 'Regular' }, 56 | { id: '1002', type: 'Chocolate' }, 57 | ], 58 | }, 59 | topping: [ 60 | { id: '5001', type: 'None' }, 61 | { id: '5002', type: 'Glazed' }, 62 | { id: '5003', type: 'Chocolate' }, 63 | { id: '5004', type: 'Maple' }, 64 | ], 65 | } 66 | 67 | const sampleArray = [data1, data2, data3] 68 | 69 | const sampleObject = { 70 | [data1.id]: data1, 71 | [data2.id]: data2, 72 | [data3.id]: data3, 73 | } 74 | 75 | function deepClone(x: any) { 76 | return JSON.parse(JSON.stringify(x)) 77 | } 78 | 79 | export type SampleArrayType = typeof sampleArray 80 | export type SampleObjectType = typeof sampleObject 81 | 82 | export function createSampleArray(): SampleArrayType { 83 | return deepClone(sampleArray) 84 | } 85 | 86 | export function createSampleObject(): SampleObjectType { 87 | return deepClone(sampleObject) 88 | } 89 | -------------------------------------------------------------------------------- /packages/immer-yjs/src/types.ts: -------------------------------------------------------------------------------- 1 | export type JSONPrimitive = string | number | boolean | null 2 | 3 | export type JSONValue = JSONPrimitive | JSONObject | JSONArray 4 | 5 | export type JSONObject = { [member: string]: JSONValue } 6 | 7 | export interface JSONArray extends Array {} 8 | -------------------------------------------------------------------------------- /packages/immer-yjs/src/util.ts: -------------------------------------------------------------------------------- 1 | import * as Y from 'yjs' 2 | 3 | import { JSONArray, JSONObject, JSONPrimitive, JSONValue } from './types' 4 | 5 | export function isJSONPrimitive(v: JSONValue): v is JSONPrimitive { 6 | const t = typeof v 7 | return t === 'string' || t === 'number' || t === 'boolean' || v === null 8 | } 9 | 10 | export function isJSONArray(v: JSONValue): v is JSONArray { 11 | return Array.isArray(v) 12 | } 13 | 14 | export function isJSONObject(v: JSONValue): v is JSONObject { 15 | return !isJSONArray(v) && typeof v === 'object' 16 | } 17 | 18 | export function toYDataType(v: JSONValue) { 19 | if (isJSONPrimitive(v)) { 20 | return v 21 | } else if (isJSONArray(v)) { 22 | const arr = new Y.Array() 23 | applyJsonArray(arr, v) 24 | return arr 25 | } else if (isJSONObject(v)) { 26 | const map = new Y.Map() 27 | applyJsonObject(map, v) 28 | return map 29 | } else { 30 | return undefined 31 | } 32 | } 33 | 34 | export function applyJsonArray(dest: Y.Array, source: JSONArray) { 35 | dest.push(source.map(toYDataType)) 36 | } 37 | 38 | export function applyJsonObject(dest: Y.Map, source: JSONObject) { 39 | Object.entries(source).forEach(([k, v]) => { 40 | dest.set(k, toYDataType(v)) 41 | }) 42 | } 43 | 44 | export function toPlainValue(v: Y.Map | Y.Array | JSONValue) { 45 | if (v instanceof Y.Map || v instanceof Y.Array) { 46 | return v.toJSON() as JSONObject | JSONArray 47 | } else { 48 | return v 49 | } 50 | } 51 | 52 | export function notImplemented() { 53 | throw new Error('not implemented') 54 | } 55 | -------------------------------------------------------------------------------- /packages/immer-yjs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["ESNext", "DOM"], 7 | "moduleResolution": "Node", 8 | "strict": true, 9 | "sourceMap": true, 10 | "resolveJsonModule": true, 11 | "esModuleInterop": true, 12 | "noEmit": true, 13 | "noUnusedLocals": false, 14 | "noUnusedParameters": false, 15 | "noImplicitReturns": true 16 | }, 17 | "include": ["src"] 18 | } 19 | -------------------------------------------------------------------------------- /packages/immer-yjs/vite.config.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path' 2 | import { defineConfig } from 'vite' 3 | import dts from 'vite-plugin-dts' 4 | 5 | export default defineConfig({ 6 | build: { 7 | lib: { 8 | entry: path.resolve(__dirname, 'src/index.ts'), 9 | name: 'immer-yjs', 10 | formats: ['es', 'umd'], 11 | }, 12 | rollupOptions: { 13 | external: ['yjs', 'immer'], 14 | output: { 15 | globals: { 16 | yjs: 'yjs', 17 | immer: 'immer', 18 | }, 19 | // Since we publish our ./src folder, there's no point 20 | // in bloating sourcemaps with another copy of it. 21 | sourcemapExcludeSources: true, 22 | }, 23 | }, 24 | sourcemap: true, 25 | // Reduce bloat from legacy polyfills. 26 | target: 'esnext', 27 | // Leave minification up to applications. 28 | minify: false, 29 | }, 30 | plugins: [dts()], 31 | }) 32 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # immer-yjs 2 | 3 | [![npm](https://img.shields.io/npm/v/immer-yjs.svg)](https://www.npmjs.com/package/immer-yjs) 4 | [![size](https://img.shields.io/bundlephobia/minzip/immer-yjs)](https://bundlephobia.com/result?p=immer-yjs) 5 | 6 | Combine immer & y.js 7 | 8 | # What is this 9 | 10 | [immer](https://github.com/immerjs/immer) is a library for easy immutable data manipulation using plain json structure. [y.js](https://github.com/yjs/yjs) is a CRDT library with mutation-based API. `immer-yjs` allows manipulating `y.js` data types with the api provided by `immer`. 11 | 12 | - Two-way binding between y.js and plain (nested) json object/array. 13 | - Efficient snapshot update with structural sharing, same as `immer`. 14 | - Updates to `y.js` are explicitly batched in transaction, you control the transaction boundary. 15 | - Always opt-in, non-intrusive by nature (the snapshot is just a plain object after all). 16 | - The snapshot shape & y.js binding aims to be fully customizable. 17 | - Typescript all the way (pure js is also supported). 18 | - Code is simple and small, no magic hidden behind, no vendor-locking. 19 | 20 | Do: 21 | 22 | ```js 23 | // any operation supported by immer 24 | update((state) => { 25 | state.nested[0].key = { 26 | id: 123, 27 | p1: 'a', 28 | p2: ['a', 'b', 'c'], 29 | } 30 | }) 31 | ``` 32 | 33 | Instead of: 34 | 35 | ```js 36 | Y.transact(state.doc, () => { 37 | const val = new Y.Map() 38 | val.set('id', 123) 39 | val.set('p1', 'a') 40 | 41 | const arr = new Y.Array() 42 | arr.push(['a', 'b', 'c']) 43 | val.set('p2', arr) 44 | 45 | state.get('nested').get(0).set('key', val) 46 | }) 47 | ``` 48 | 49 | # Installation 50 | 51 | `yarn add immer-yjs immer yjs` 52 | 53 | # Documentation 54 | 55 | 1. `import { bind } from 'immer-yjs'`. 56 | 2. Create a binder: `const binder = bind(doc.getMap("state"))`. 57 | 3. Add subscription to the snapshot: `binder.subscribe(listener)`. 58 | 1. Mutations in `y.js` data types will trigger snapshot subscriptions. 59 | 2. Calling `update(...)` (similar to `produce(...)` in `immer`) will update their corresponding `y.js` types and also trigger snapshot subscriptions. 60 | 4. Call `binder.get()` to get the latest snapshot. 61 | 5. (Optionally) call `binder.unbind()` to release the observer. 62 | 63 | `Y.Map` binds to plain object `{}`, `Y.Array` binds to plain array `[]`, and any level of nested `Y.Map`/`Y.Array` binds to nested plain json object/array respectively. 64 | 65 | `Y.XmlElement` & `Y.Text` have no equivalent to json data types, so they are not supported by default. If you want to use them, please use the `y.js` top-level type (e.g. `doc.getText("xxx")`) directly, or see **Customize binding & schema** section below. 66 | 67 | ## With Vanilla Javascript/Typescript 68 | 69 | 🚀🚀🚀 [Please see the test for detailed usage.](https://github.com/sep2/immer-yjs/blob/main/packages/immer-yjs/src/immer-yjs.test.ts) 🚀🚀🚀 70 | 71 | ## Customize binding & schema 72 | 73 | Use the [`applyPatch` option](https://github.com/sep2/immer-yjs/blob/6b50fdfa85c9ca8ac850075bda7ef456337c7d55/packages/immer-yjs/src/immer-yjs.test.ts#L136) to customize it. Check the [discussion](https://github.com/sep2/immer-yjs/issues/1) for detailed background. **This section will likely be removed since it is not functioning properly. A new impl may be needed** 74 | 75 | ## Integration with React 76 | 77 | By leveraging [useSyncExternalStoreWithSelector](https://github.com/reactwg/react-18/discussions/86). 78 | 79 | ```tsx 80 | import { bind } from 'immer-yjs' 81 | 82 | // define state shape (not necessarily in js) 83 | interface State { 84 | // any nested plain json data type 85 | nested: { count: number }[] 86 | } 87 | 88 | const doc = new Y.Doc() 89 | 90 | // optionally set initial data to doc.getMap('data') 91 | 92 | // define store 93 | const binder = bind(doc.getMap('data')) 94 | 95 | // define a helper hook 96 | function useImmerYjs(selector: (state: State) => Selection) { 97 | const selection = useSyncExternalStoreWithSelector(binder.subscribe, binder.get, binder.get, selector) 98 | 99 | return [selection, binder.update] 100 | } 101 | 102 | // optionally set initial data 103 | binder.update((state) => { 104 | state.nested = [{ count: 0 }] 105 | }) 106 | 107 | // use in component 108 | function Component() { 109 | const [count, update] = useImmerYjs((s) => s.nested[0].count) 110 | 111 | const handleClick = () => { 112 | update((s) => { 113 | // any operation supported by immer 114 | s.nested[0].count++ 115 | }) 116 | } 117 | 118 | // will only rerender when 'count' changed 119 | return 120 | } 121 | 122 | // when done 123 | binder.unbind() 124 | ``` 125 | 126 | ## Integration with other frameworks 127 | 128 | Please submit with sample code by PR, helps needed. 129 | 130 | # Demos 131 | 132 | Data will sync between multiple browser tabs automatically. 133 | 134 | - [Messages Object](https://codesandbox.io/s/immer-yjs-demo-6e0znb) 135 | 136 | # Changelog 137 | 138 | [Changelog](https://github.com/sep2/immer-yjs/blob/main/packages/immer-yjs/CHANGELOG.md) 139 | 140 | # Similar projects 141 | 142 | [valtio-yjs](https://github.com/dai-shi/valtio-yjs) 143 | --------------------------------------------------------------------------------