├── src ├── react-env.d.ts ├── react-app-env.d.ts ├── misc │ ├── useSocket.js │ ├── gifts.json │ ├── users.js │ └── index.css ├── gifts.js ├── index.js └── gifts.spec.js ├── .vscode └── settings.json ├── public ├── favicon.ico ├── manifest.json └── index.html ├── README.md ├── .gitignore ├── tsconfig.json ├── package.json └── server.js /src/react-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mweststrate/immer-gifts/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This is the demo project of the egghead.io advanced immer course. 2 | 3 | To run 4 | 5 | ``` 6 | yarn 7 | yarn start-server & 8 | yarn start 9 | ``` 10 | 11 | Use `yarn test` to run tests. 12 | 13 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 14 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Immer Gifts", 3 | "name": "In-depth immer course demo", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "noEmit": true, 16 | "jsx": "preserve" 17 | }, 18 | "include": ["src"] 19 | } 20 | -------------------------------------------------------------------------------- /src/misc/useSocket.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useCallback, useRef } from "react" 2 | 3 | export function useSocket(url, onMessage) { 4 | const socket = useRef() 5 | const msgHandler = useRef() 6 | msgHandler.current = onMessage 7 | 8 | useEffect(() => { 9 | const createdSocket = new WebSocket(url) 10 | createdSocket.onmessage = event => { 11 | const data = JSON.parse(event.data) 12 | msgHandler.current(data) 13 | } 14 | socket.current = createdSocket 15 | console.log("created socket to " + url) 16 | return () => { 17 | console.log("socket disconnected") 18 | createdSocket.close() 19 | } 20 | }, [url]) 21 | 22 | return useCallback(data => { 23 | socket.current.send(JSON.stringify(data)) 24 | }, []) 25 | } 26 | -------------------------------------------------------------------------------- /src/misc/gifts.json: -------------------------------------------------------------------------------- 1 | { 2 | "ade6f3cb-c36e-45c2-b4d7-d1e50d129801": { 3 | "id": "ade6f3cb-c36e-45c2-b4d7-d1e50d129801", 4 | "description": "Immer license", 5 | "image": "https://raw.githubusercontent.com/immerjs/immer/master/images/immer-logo.png", 6 | "reservedBy": 5 7 | }, 8 | "fe54c511-737a-4e6f-bc50-aa221bcf43cd": { 9 | "id": "fe54c511-737a-4e6f-bc50-aa221bcf43cd", 10 | "description": "Egghead.io subscription", 11 | "image": "https://pbs.twimg.com/profile_images/735242324293210112/H8YfgQHP_400x400.jpg", 12 | "reservedBy": 21 13 | }, 14 | "0acbb5ad-128e-4256-8f8d-cbc0a7737456": { 15 | "id": "0acbb5ad-128e-4256-8f8d-cbc0a7737456", 16 | "description": "The Complete Circle Series", 17 | "image": "https://images-na.ssl-images-amazon.com/images/I/41kCKME78kL.jpg" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/misc/users.js: -------------------------------------------------------------------------------- 1 | export const allUsers = [ 2 | "🐶", 3 | "🐱", 4 | "🐭", 5 | "🐹", 6 | "🐰", 7 | "🦊", 8 | "🐻", 9 | "🐼", 10 | "🐨", 11 | "🐯", 12 | "🦁", 13 | "🐮", 14 | "🐷", 15 | "🐸", 16 | "🐒", 17 | "🦇", 18 | "🦉", 19 | "🦅", 20 | "🦆", 21 | "🐦", 22 | "🐧", 23 | "🐔", 24 | "🐺", 25 | "🐗", 26 | "🐴", 27 | "🦄", 28 | "🐝", 29 | "🐛", 30 | "🦋", 31 | "🐌", 32 | "🐜", 33 | "🐢" 34 | ].map((emoji, idx) => ({ 35 | id: idx, 36 | name: emoji 37 | })) 38 | 39 | export function getCurrentUser() { 40 | if (typeof sessionStorage === "undefined") return { id: -1, name: "Test" } // not a browser no current user 41 | // picks a random user, and stores it on the session storage to preserve identity during hot reloads 42 | const currentUserId = sessionStorage.getItem("user") || Math.round(Math.random() * (allUsers.length - 1)) 43 | sessionStorage.setItem("user", currentUserId) 44 | return allUsers[parseInt(currentUserId)] 45 | } 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "immer-gifts", 3 | "type": "module", 4 | "version": "0.1.0", 5 | "private": true, 6 | "dependencies": { 7 | "immer": "4.0.0", 8 | "react": "^16.8.6", 9 | "react-dom": "^16.8.6", 10 | "react-scripts": "3.0.1", 11 | "use-immer": "^0.3.3", 12 | "uuid": "^3.3.3", 13 | "ws": "^7.1.0" 14 | }, 15 | "scripts": { 16 | "start": "react-scripts start", 17 | "build": "react-scripts build", 18 | "test": "react-scripts test", 19 | "eject": "react-scripts eject", 20 | "start-server": "node -r esm server.js" 21 | }, 22 | "eslintConfig": { 23 | "extends": "react-app" 24 | }, 25 | "browserslist": { 26 | "production": [ 27 | ">0.2%", 28 | "not dead", 29 | "not op_mini all" 30 | ], 31 | "development": [ 32 | "last 1 chrome version", 33 | "last 1 firefox version", 34 | "last 1 safari version" 35 | ] 36 | }, 37 | "devDependencies": { 38 | "@types/jest": "^24.0.15", 39 | "@types/react": "^16.9.2", 40 | "@types/react-dom": "^16.9.0", 41 | "deepfreeze": "^2.0.0", 42 | "esm": "^3.2.25", 43 | "node-fetch": "^2.6.0", 44 | "prettier": "^1.18.2", 45 | "typescript": "^3.5.3" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/misc/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | font: 12pt "Century Gothic", Futura, sans-serif; 3 | margin: 20px; 4 | background: #3a2418; 5 | color: white; 6 | } 7 | 8 | h1 { 9 | color: orange; 10 | font-size: 40pt; 11 | } 12 | 13 | h2 { 14 | color: orange; 15 | } 16 | 17 | .app { 18 | margin: auto; 19 | max-width: 800px; 20 | } 21 | 22 | .actions { 23 | padding: 8px 0; 24 | margin-bottom: 20px; 25 | border-bottom: 1px dashed orangered; 26 | border-top: 1px dashed orangered; 27 | } 28 | 29 | .actions button { 30 | margin-right: 8px; 31 | } 32 | 33 | button { 34 | cursor: pointer; 35 | font-size: 14pt; 36 | padding: 4px 8px; 37 | } 38 | 39 | .gift { 40 | border-radius: 4px; 41 | display: flex; 42 | flex-direction: row; 43 | align-items: center; 44 | margin-bottom: 20px; 45 | } 46 | 47 | .gift img { 48 | height: 80px; 49 | width: 80px; 50 | object-fit: cover; 51 | border: 4px solid orange; 52 | border-radius: 80px; 53 | } 54 | 55 | .gift .description { 56 | text-align: center; 57 | flex-grow: 1; 58 | } 59 | 60 | .gift.reserved h2 { 61 | text-decoration: line-through; 62 | color: gray; 63 | } 64 | 65 | .gift .reservation { 66 | width: 120px; 67 | text-align: center; 68 | } 69 | 70 | .gift .reservation span { 71 | font-size: 30pt; 72 | } 73 | 74 | .gift .reservation span::before { 75 | content: "reserved by"; 76 | font-size: 12pt; 77 | color: gray; 78 | } 79 | 80 | .gift .reservation button { 81 | margin-left: 8px; 82 | } 83 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 22 | React App 23 | 24 | 25 | 26 |
27 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | import { Server as WebSocketServer } from "ws" 2 | import { applyPatches, produceWithPatches } from "immer" 3 | 4 | import gifts from "./src/misc/gifts.json" 5 | 6 | const wss = new WebSocketServer({ port: 5001 }) 7 | 8 | /** 9 | * Connected clients 10 | */ 11 | const connections = [] 12 | 13 | /** 14 | * State as seen by server 15 | */ 16 | let history = [] 17 | 18 | wss.on("connection", function connection(ws) { 19 | /** 20 | * Assign player, save WS connection 21 | */ 22 | connections.push(ws) 23 | console.log("New client connected") 24 | 25 | /** 26 | * Hanle new messages / closing 27 | */ 28 | ws.on("message", function incoming(message) { 29 | console.log(message) 30 | history.push(...JSON.parse(message)) 31 | connections 32 | .filter(client => client !== ws) 33 | .forEach(client => { 34 | client.send(message) 35 | }) 36 | }) 37 | 38 | /** 39 | * Remove connection upon close 40 | */ 41 | ws.on("close", function() { 42 | const idx = connections.indexOf(ws) 43 | if (idx !== -1) connections.splice(idx, 1) 44 | }) 45 | 46 | /** 47 | * Send initial state 48 | */ 49 | ws.send(JSON.stringify(history)) 50 | }) 51 | 52 | const initialState = { gifts } 53 | 54 | export function compressHistory(currentPatches) { 55 | console.log("COMPRESSING HISTORY") 56 | const [_finalState, patches] = produceWithPatches(initialState, draft => { 57 | return applyPatches(draft, currentPatches) 58 | }) 59 | console.log(`compressed patches from ${history.length} to ${patches.length} patches`) 60 | console.log(JSON.stringify(patches, null, 2)) 61 | return patches 62 | } 63 | 64 | setInterval(() => { 65 | history = compressHistory(history) 66 | }, 5000) 67 | -------------------------------------------------------------------------------- /src/gifts.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable default-case */ 2 | import produce, { produceWithPatches, applyPatches } from "immer" 3 | 4 | import { allUsers, getCurrentUser } from "./misc/users" 5 | import defaultGifts from "./misc/gifts" 6 | 7 | export const giftsRecipe = (draft, action) => { 8 | switch (action.type) { 9 | case "ADD_GIFT": 10 | const { id, description, image } = action 11 | draft.gifts[id] = { 12 | id, 13 | description, 14 | image, 15 | reservedBy: undefined 16 | } 17 | break 18 | case "TOGGLE_RESERVATION": 19 | const gift = draft.gifts[action.id] 20 | gift.reservedBy = 21 | gift.reservedBy === undefined 22 | ? draft.currentUser.id 23 | : gift.reservedBy === draft.currentUser.id 24 | ? undefined 25 | : gift.reservedBy 26 | break 27 | case "ADD_BOOK": 28 | const { book } = action 29 | const isbn = book.identifiers.isbn_10[0] 30 | draft.gifts[isbn] = { 31 | id: isbn, 32 | description: book.title, 33 | image: book.cover.medium, 34 | reservedBy: undefined 35 | } 36 | break 37 | case "RESET": 38 | draft.gifts = getInitialState().gifts 39 | break 40 | case "APPLY_PATCHES": 41 | return applyPatches(draft, action.patches) 42 | } 43 | } 44 | 45 | export const giftsReducer = produce(giftsRecipe) 46 | 47 | export const patchGeneratingGiftsReducer = produceWithPatches(giftsRecipe) 48 | 49 | export async function getBookDetails(isbn) { 50 | const response = await fetch(`http://openlibrary.org/api/books?bibkeys=ISBN:${isbn}&jscmd=data&format=json`, { 51 | mode: "cors" 52 | }) 53 | const book = (await response.json())["ISBN:" + isbn] 54 | return book 55 | } 56 | 57 | export function getInitialState() { 58 | return { 59 | users: allUsers, 60 | currentUser: getCurrentUser(), 61 | gifts: defaultGifts 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable react-hooks/exhaustive-deps */ 2 | import React, { useState, memo, useCallback, useRef } from "react" 3 | import ReactDOM from "react-dom" 4 | import uuidv4 from "uuid/v4" 5 | 6 | import { getInitialState, getBookDetails, patchGeneratingGiftsReducer } from "./gifts" 7 | 8 | import "./misc/index.css" 9 | import { useSocket } from "./misc/useSocket" 10 | import { giftsReducer } from "./gifts" 11 | 12 | const Gift = memo(({ gift, users, currentUser, onReserve }) => ( 13 |
14 | gift 15 |
16 |

{gift.description}

17 |
18 |
19 | {!gift.reservedBy || gift.reservedBy === currentUser.id ? ( 20 | 21 | ) : ( 22 | {users[gift.reservedBy].name} 23 | )} 24 |
25 |
26 | )) 27 | 28 | function GiftList() { 29 | const [state, setState] = useState(() => getInitialState()) 30 | const undoStack = useRef([]) 31 | const undoStackPointer = useRef(-1) 32 | 33 | const { users, gifts, currentUser } = state 34 | 35 | const dispatch = useCallback((action, undoable = true) => { 36 | setState(currentState => { 37 | const [nextState, patches, inversePatches] = patchGeneratingGiftsReducer(currentState, action) 38 | send(patches) // always send patches 39 | if (undoable) { 40 | const pointer = ++undoStackPointer.current 41 | undoStack.current.length = pointer 42 | undoStack.current[pointer] = { patches, inversePatches } 43 | } 44 | return nextState 45 | }) 46 | }, []) 47 | 48 | const send = useSocket("ws://localhost:5001", function onMessage(patches) { 49 | // we received some patches 50 | setState(state => giftsReducer(state, { type: "APPLY_PATCHES", patches })) 51 | }) 52 | 53 | const handleUndo = () => { 54 | if (undoStackPointer.current < 0) return 55 | const patches = undoStack.current[undoStackPointer.current].inversePatches 56 | dispatch({ type: "APPLY_PATCHES", patches }, false) 57 | undoStackPointer.current-- 58 | } 59 | 60 | const handleRedo = () => { 61 | if (undoStackPointer.current === undoStack.current.length - 1) return 62 | undoStackPointer.current++ 63 | const patches = undoStack.current[undoStackPointer.current].patches 64 | dispatch({ type: "APPLY_PATCHES", patches }, false) 65 | } 66 | 67 | const handleAdd = () => { 68 | const description = prompt("Gift to add") 69 | if (description) 70 | dispatch({ 71 | type: "ADD_GIFT", 72 | id: uuidv4(), 73 | description, 74 | image: `https://picsum.photos/id/${Math.round(Math.random() * 1000)}/200/200` 75 | }) 76 | } 77 | 78 | const handleReserve = useCallback(id => { 79 | dispatch({ 80 | type: "TOGGLE_RESERVATION", 81 | id 82 | }) 83 | }, []) 84 | 85 | const handleAddBook = async () => { 86 | const isbn = prompt("Enter ISBN number", "0201558025") 87 | if (isbn) { 88 | const book = await getBookDetails(isbn) 89 | dispatch({ 90 | type: "ADD_BOOK", 91 | book 92 | }) 93 | } 94 | } 95 | 96 | const handleReset = () => { 97 | dispatch({ type: "RESET" }) 98 | } 99 | 100 | return ( 101 |
102 |
103 |

Hi, {currentUser.name}

104 |
105 |
106 | 107 | 108 | 109 | 112 | 115 |
116 |
117 | {Object.values(gifts).map(gift => ( 118 | 119 | ))} 120 |
121 |
122 | ) 123 | } 124 | 125 | ReactDOM.render(, document.getElementById("root")) 126 | -------------------------------------------------------------------------------- /src/gifts.spec.js: -------------------------------------------------------------------------------- 1 | import { giftsReducer, getBookDetails, patchGeneratingGiftsReducer } from "./gifts" 2 | import { applyPatches } from "immer" 3 | 4 | const initialState = { 5 | users: [ 6 | { 7 | id: 1, 8 | name: "Test user" 9 | }, 10 | { 11 | id: 2, 12 | name: "Someone else" 13 | } 14 | ], 15 | currentUser: { 16 | id: 1, 17 | name: "Test user" 18 | }, 19 | gifts: { 20 | immer_license: { 21 | id: "immer_license", 22 | description: "Immer license", 23 | image: "https://raw.githubusercontent.com/immerjs/immer/master/images/immer-logo.png", 24 | reservedBy: 2 25 | }, 26 | egghead_subscription: { 27 | id: "egghead_subscription", 28 | description: "Egghead.io subscription", 29 | image: "https://pbs.twimg.com/profile_images/735242324293210112/H8YfgQHP_400x400.jpg", 30 | reservedBy: undefined 31 | } 32 | } 33 | } 34 | 35 | describe("Adding a gift", () => { 36 | const nextState = giftsReducer(initialState, { 37 | type: "ADD_GIFT", 38 | id: "mug", 39 | description: "Coffee mug", 40 | image: "" 41 | }) 42 | 43 | test("added a gift to the collection", () => { 44 | expect(Object.keys(nextState.gifts).length).toBe(3) 45 | }) 46 | 47 | test("didn't modify the original state", () => { 48 | expect(Object.keys(initialState.gifts).length).toBe(2) 49 | }) 50 | }) 51 | 52 | describe("Reserving an unreserved gift", () => { 53 | const nextState = giftsReducer(initialState, { 54 | type: "TOGGLE_RESERVATION", 55 | id: "egghead_subscription" 56 | }) 57 | 58 | test("correctly stores reservedBy", () => { 59 | expect(nextState.gifts["egghead_subscription"].reservedBy).toBe(1) // Test user 60 | }) 61 | 62 | test("didn't the original state", () => { 63 | expect(initialState.gifts["egghead_subscription"].reservedBy).toBe(undefined) 64 | }) 65 | 66 | test("does structurally share unchanged state parts", () => { 67 | expect(nextState.gifts["immer_license"]).toBe(initialState.gifts["immer_license"]) 68 | }) 69 | 70 | test("can't accidentally modify the produced state", () => { 71 | expect(() => { 72 | nextState.gifts["egghead_subscription"].reservedBy = undefined 73 | }).toThrow("read only") 74 | }) 75 | }) 76 | 77 | describe("Reserving an unreserved gift with patches", () => { 78 | const [nextState, patches, inversePatches] = patchGeneratingGiftsReducer(initialState, { 79 | type: "TOGGLE_RESERVATION", 80 | id: "egghead_subscription" 81 | }) 82 | 83 | test("correctly stores reservedBy", () => { 84 | expect(nextState.gifts["egghead_subscription"].reservedBy).toBe(1) // Test user 85 | }) 86 | 87 | test("generates the correct patches", () => { 88 | expect(patches).toEqual([ 89 | { 90 | op: "replace", 91 | path: ["gifts", "egghead_subscription", "reservedBy"], 92 | value: 1 93 | } 94 | ]) 95 | }) 96 | 97 | test("generates the correct inverse patches", () => { 98 | expect(inversePatches).toMatchInlineSnapshot(` 99 | Array [ 100 | Object { 101 | "op": "replace", 102 | "path": Array [ 103 | "gifts", 104 | "egghead_subscription", 105 | "reservedBy", 106 | ], 107 | "value": undefined, 108 | }, 109 | ] 110 | `) 111 | }) 112 | 113 | test("replaying patches produces the same state - 1", () => { 114 | expect(applyPatches(initialState, patches)).toEqual(nextState) 115 | }) 116 | 117 | test("reversing patches goes back to the original", () => { 118 | expect(applyPatches(nextState, inversePatches)).toEqual(initialState) 119 | }) 120 | 121 | test("replaying patches produces the same state - 2", () => { 122 | expect( 123 | giftsReducer(initialState, { 124 | type: "APPLY_PATCHES", 125 | patches 126 | }) 127 | ).toEqual(nextState) 128 | }) 129 | }) 130 | 131 | describe("Reserving an already reserved gift", () => { 132 | const nextState = giftsReducer(initialState, { 133 | type: "TOGGLE_RESERVATION", 134 | id: "immer_license" 135 | }) 136 | 137 | test("preserves stored reservedBy", () => { 138 | expect(nextState.gifts["immer_license"].reservedBy).toBe(2) // Someone else 139 | }) 140 | 141 | test("still produces a new gift", () => { 142 | expect(nextState.gifts["immer_license"]).toEqual(initialState.gifts["immer_license"]) 143 | expect(nextState.gifts["immer_license"]).toBe(initialState.gifts["immer_license"]) 144 | }) 145 | 146 | test("still produces a new state", () => { 147 | expect(nextState).toEqual(initialState) 148 | expect(nextState).toBe(initialState) 149 | }) 150 | }) 151 | 152 | describe("Can add books async", () => { 153 | test("Can add math book", async () => { 154 | const book = await getBookDetails("0201558025") 155 | const nextState = giftsReducer(initialState, { type: "ADD_BOOK", book }) 156 | expect(nextState.gifts["0201558025"].description).toBe("Concrete mathematics") 157 | }) 158 | 159 | test("Can add two books in parallel", async () => { 160 | const promise1 = getBookDetails("0201558025") 161 | const promise2 = getBookDetails("9781598560169") 162 | const nextState = giftsReducer(giftsReducer(initialState, { type: "ADD_BOOK", book: await promise1 }), { 163 | type: "ADD_BOOK", 164 | book: await promise2 165 | }) 166 | expect(Object.keys(nextState.gifts).length).toBe(4) 167 | }) 168 | }) 169 | --------------------------------------------------------------------------------