├── .editorconfig ├── .eslintrc.json ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── feature_request.md │ └── none-of-the-above.md └── workflows │ └── nodejs.yml ├── .gitignore ├── .prettierrc.js ├── LICENSE.md ├── README.md ├── cycle.png ├── demo ├── .eslintrc.json ├── .gitignore ├── .prettierrc.js ├── README.md ├── cypress.json ├── cypress │ ├── fixtures │ │ └── example.json │ ├── integration │ │ ├── bigTreePage.test.js │ │ ├── productPage.test.js │ │ └── todoMvcPage.test.js │ ├── plugins │ │ └── index.js │ └── support │ │ ├── commands.js │ │ └── index.js ├── package-lock.json ├── package.json ├── public │ ├── browser.html │ ├── favicon.ico │ └── index.html └── src │ ├── App.css │ ├── App.js │ ├── index.css │ ├── index.js │ ├── pages │ ├── bigTree │ │ ├── BigTree.js │ │ ├── Item.js │ │ ├── selectors.js │ │ ├── updaters.js │ │ └── utils.js │ ├── products │ │ ├── Product.js │ │ ├── Product.module.css │ │ ├── Products.js │ │ ├── Products.module.css │ │ ├── loadProducts.js │ │ ├── makeData.js │ │ └── propTypes │ │ │ ├── ProductPagePropType.js │ │ │ └── ProductPropType.js │ ├── todomvc │ │ ├── components │ │ │ ├── Footer.js │ │ │ ├── Header.js │ │ │ ├── Link.js │ │ │ ├── MainSection.js │ │ │ ├── TodoItem.js │ │ │ ├── TodoList.js │ │ │ ├── TodoMvcPage.css │ │ │ ├── TodoMvcPage.js │ │ │ └── TodoTextInput.js │ │ ├── propTypes │ │ │ ├── TodoMvcPropType.js │ │ │ └── TodoPropType.js │ │ ├── selectors │ │ │ ├── getVisibleTodos.js │ │ │ └── loadTodoData.js │ │ └── updaters │ │ │ └── deleteTodo.js │ └── updates │ │ ├── Child.js │ │ ├── GridItem.js │ │ ├── GridItem.module.css │ │ ├── Parent.js │ │ ├── Updates.js │ │ └── throttledUpdate.js │ ├── propTypes │ └── StorePropType.js │ └── shared │ ├── Theme.js │ └── constants.js ├── index.cjs.js ├── index.esm.js ├── jest.config.js ├── jestSetup.js ├── package-lock.json ├── package.json ├── rollup.config.js ├── src ├── collect.tsx ├── index.ts ├── proxyManager.ts ├── shared │ ├── batchedUpdates.ts │ ├── constants.ts │ ├── debug.ts │ ├── ls.ts │ ├── paths.ts │ ├── propTypes.ts │ ├── pubSub.ts │ ├── state.ts │ ├── timeTravel.ts │ ├── types.ts │ └── utils.ts ├── store.ts ├── types.d.ts └── updateManager.ts ├── tests ├── anti │ └── hiddenProperties.test.tsx ├── integration │ ├── README.md │ ├── TaskListTest │ │ ├── App.tsx │ │ ├── Notifications.tsx │ │ ├── Task.tsx │ │ ├── TaskList.test.tsx │ │ ├── TaskList.tsx │ │ └── loadTasks.ts │ ├── isolation.test.tsx │ ├── listening.test.tsx │ ├── newComponentsGetNewStore.test.tsx │ ├── nodeJs.js │ ├── propsInheritance.test.tsx │ ├── readFromTwoStores.test.tsx │ ├── readRemovedProperty.test.ts │ ├── renderBatching.test.tsx │ ├── setStoreTwiceInOnClick.test.tsx │ ├── sharedStoreProps.test.tsx │ ├── siblingIsolation.test.tsx │ ├── storeVPlainObject.test.ts │ ├── types.test.ts │ ├── unCollectedChildren.test.tsx │ └── updating.test.tsx ├── objectTypes │ ├── array.test.tsx │ ├── map.test.tsx │ └── set.test.tsx ├── react │ ├── basicClassComponent.test.tsx │ ├── basicFunctionalComponent.test.tsx │ ├── componentDidMount.test.tsx │ ├── componentDidUpdate.test.tsx │ ├── componentsInTheStore.test.tsx │ ├── forwardRefClass.test.tsx │ ├── forwardRefFc.test.tsx │ ├── hooks.test.tsx │ └── propTypes.test.tsx ├── testUtils.tsx └── unit │ ├── batch.test.tsx │ ├── debug.test.tsx │ ├── initStore.test.ts │ ├── noWastedUpdates.test.ts │ ├── nonReactStatics.test.tsx │ ├── propTypes.test.tsx │ ├── setting.test.ts │ ├── timeTravel.test.tsx │ ├── useProps.test.tsx │ └── utils.test.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 2 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "parserOptions": { 5 | "project": "./tsconfig.json" 6 | }, 7 | "plugins": [ 8 | "@typescript-eslint" 9 | ], 10 | "extends": [ 11 | "airbnb-typescript", 12 | "plugin:prettier/recommended", 13 | "prettier/@typescript-eslint", 14 | "prettier/react" 15 | ], 16 | "env": { 17 | "node": true, 18 | "browser": true, 19 | "jest": true, 20 | "es2020": false 21 | }, 22 | "rules": { 23 | "import/prefer-default-export": "off", 24 | "jsx-a11y/label-has-associated-control": "off", 25 | "no-console": [ 26 | "warn", 27 | { 28 | "allow": [ 29 | "info", 30 | "warn", 31 | "error", 32 | "group", 33 | "groupCollapsed", 34 | "groupEnd", 35 | "time", 36 | "timeEnd" 37 | ] 38 | } 39 | ], 40 | "no-param-reassign": [ 41 | "error", 42 | { 43 | "props": true, 44 | "ignorePropertyModificationsFor": [ 45 | "mutableTarget", 46 | "task", 47 | "store" 48 | ] 49 | } 50 | ], 51 | "no-plusplus": "off", 52 | "no-underscore-dangle": [ 53 | "error", 54 | { 55 | "allow": [ 56 | "__RR__", 57 | "_isMounted", 58 | "_isMounting", 59 | "_isRendering", 60 | "_name" 61 | ] 62 | } 63 | ], 64 | "react/button-has-type": "off", 65 | "react/destructuring-assignment": "off", 66 | "react/jsx-props-no-spreading": "off", 67 | "react/prop-types": "off", 68 | "react/state-in-constructor": ["error", "never"], 69 | "react/static-property-placement": ["error", "static public field"] 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Recollect version** 11 | 12 | **Describe the bug** 13 | 14 | 15 | If possible, replicate the issue in a fork of this CodeSandbox: https://codesandbox.io/s/react-recollect-demo-simple-70x9i 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/none-of-the-above.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: None of the above 3 | about: Not a bug or feature request 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | strategy: 11 | matrix: 12 | node-version: [10, 12, 13] 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Use Node.js ${{ matrix.node-version }} 17 | uses: actions/setup-node@v1 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | - run: npm install 21 | - run: npm run check:all 22 | env: 23 | CI: true 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .cache 2 | .eslintcache 3 | .idea 4 | node_modules 5 | dist 6 | /private/ 7 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | proseWrap: 'always', 3 | singleQuote: true, 4 | }; 5 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 David Gilbertson 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 | -------------------------------------------------------------------------------- /cycle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidgilbertson/react-recollect/388e96311c19b136ed81810ffc856158053c19d5/cycle.png -------------------------------------------------------------------------------- /demo/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": [ 4 | "react-app", 5 | "airbnb", 6 | "plugin:prettier/recommended", 7 | "prettier/react", 8 | "plugin:cypress/recommended" 9 | ], 10 | "env": { 11 | "browser": true, 12 | "jest": true 13 | }, 14 | "rules": { 15 | "no-plusplus": "off", 16 | "import/prefer-default-export": "off", 17 | "jsx-a11y/anchor-is-valid": "off", 18 | "jsx-a11y/click-events-have-key-events": "off", 19 | "jsx-a11y/control-has-associated-label": "off", 20 | "jsx-a11y/interactive-supports-focus": "off", 21 | "jsx-a11y/label-has-associated-control": "off", 22 | "jsx-a11y/no-autofocus": "off", 23 | "jsx-a11y/no-noninteractive-element-interactions": "off", 24 | "jsx-a11y/no-static-element-interactions": "off", 25 | "no-param-reassign": "off", 26 | "react/button-has-type": "off", 27 | "react/destructuring-assignment": "off", 28 | "react/jsx-filename-extension": "off", 29 | "react/require-default-props": "off", 30 | "react/state-in-constructor": ["error", "never"] 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /demo/.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 | 25 | .env 26 | /debug.log 27 | /cypress/videos/ 28 | -------------------------------------------------------------------------------- /demo/.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | proseWrap: 'always', 3 | singleQuote: true, 4 | }; 5 | -------------------------------------------------------------------------------- /demo/README.md: -------------------------------------------------------------------------------- 1 | # A demo site for React Recollect 2 | 3 | ## Running locally 4 | 5 | `cd demo`, `npm i`, `npm start`. 6 | 7 | ## Developing locally 8 | 9 | To use this site when developing `react-recollect` locally, in the root of the 10 | `react-recollect` repo, run: 11 | 12 | ``` 13 | npm run build:watch 14 | ``` 15 | 16 | Then in the `/demo` site directory... 17 | 18 | ``` 19 | cd demo 20 | ``` 21 | 22 | Create a symlink for `react-recollect`: 23 | 24 | ``` 25 | npm link ../ 26 | ``` 27 | 28 | Now imports of `'react-recollect'` in the demo site will load from 29 | `react-recollect/dist`. 30 | 31 | Running `npm i` will undo this link, in which case you'll need to do it again to 32 | relink. 33 | 34 | Beware! Since this directory will have a `node_modules` directory, `react-dom` 35 | may be loaded from there, rather than the `demo/node_modules` directory. To be 36 | 100% certain you're replicating what users get, you'll want to release as a 37 | prerelease and install it from npm. This is also required to test that when the 38 | package is installed, it doesn't install any `node_modules` when it shouldn't 39 | (e.g. it should share hoistNonReactStatics with material-ui). 40 | 41 | Unlink with `npm i react-recollect`. 42 | 43 | Start the demo site: 44 | 45 | ``` 46 | npm start 47 | ``` 48 | 49 | And you're in business. 50 | 51 | If your editor complains about ESLint rules, it might be struggling with nested 52 | projects, if so, open `react-recollect/demo` as its own project. That's better 53 | for searching/etc. anyway. 54 | 55 | ## Test the UMD build 56 | 57 | To test loading Recollect via a script tag, run: 58 | 59 | ``` 60 | npm run serve:root 61 | ``` 62 | 63 | And go to http://localhost:3000/demo/public/browser.html 64 | 65 | ## Open in CodeSandbox 66 | 67 | https://codesandbox.io/s/github/davidgilbertson/react-recollect/tree/master/demo 68 | -------------------------------------------------------------------------------- /demo/cypress.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /demo/cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } 6 | -------------------------------------------------------------------------------- /demo/cypress/integration/bigTreePage.test.js: -------------------------------------------------------------------------------- 1 | it('Product page should work', () => { 2 | cy.viewport(1000, 2000); 3 | 4 | cy.visit('http://localhost:3000'); 5 | 6 | // Go to the Big tree page 7 | cy.contains('Big tree').click(); 8 | 9 | // Names are in the format: 10 | // A [parent type] with [children type] [(id)] [[renderCount]] 11 | cy.findByText('An object prop with Array children (100) [1]'); 12 | 13 | cy.findByTitle('Turn node 100 on').click(); 14 | cy.findByTitle('Turn node 100 off'); 15 | 16 | cy.findByText('An object prop with Array children (100) [2]'); 17 | 18 | cy.findByTitle('Add Object child to node 100').click(); 19 | cy.findByTitle('Add Array child to node 100').click(); 20 | cy.findByTitle('Add Map child to node 100').click(); 21 | cy.findByTitle('Add Set child to node 100').click(); 22 | 23 | // Root level now rendered 6 times 24 | cy.findByText('An object prop with Array children (100) [6]'); 25 | 26 | // Each item rendered once when added, then once as each new one was added 27 | cy.findByText('An array item with Object children (101) [4]'); 28 | cy.findByText('An array item with Array children (102) [3]'); 29 | cy.findByText('An array item with Map children (103) [2]'); 30 | cy.findByText('An array item with Set children (104) [1]'); 31 | 32 | // Add children to two siblings 33 | cy.findByTitle('Add Map child to node 101').click(); 34 | cy.findByTitle('Add Map child to node 102').click(); 35 | 36 | // Everyone renders twice more 37 | cy.findByText('An object prop with Array children (100) [8]'); 38 | cy.findByText('An array item with Object children (101) [6]'); 39 | cy.findByText('An object prop with Map children (105) [2]'); // New 40 | cy.findByText('An array item with Array children (102) [5]'); 41 | cy.findByText('An array item with Map children (106) [1]'); // New 42 | cy.findByText('An array item with Map children (103) [4]'); 43 | cy.findByText('An array item with Set children (104) [3]'); 44 | 45 | cy.findByTitle('Turn node 105 on').click(); 46 | 47 | // Re-rendered items will be all the ancestors of the changed item, plus 48 | // the immediate children of any changed item 49 | cy.findByText('An object prop with Array children (100) [9]'); 50 | cy.findByText('An array item with Object children (101) [7]'); 51 | cy.findByText('An object prop with Map children (105) [3]'); 52 | cy.findByText('An array item with Array children (102) [6]'); 53 | cy.findByText('An array item with Map children (106) [1]'); // No re-render 54 | cy.findByText('An array item with Map children (103) [5]'); 55 | cy.findByText('An array item with Set children (104) [4]'); 56 | 57 | cy.findByPlaceholderText('Notes for node 106').type('0123456789'); 58 | 59 | cy.findByText('An object prop with Array children (100) [19]'); 60 | cy.findByText('An array item with Object children (101) [17]'); 61 | cy.findByText('An object prop with Map children (105) [3]'); // No re-render 62 | cy.findByText('An array item with Array children (102) [16]'); 63 | cy.findByText('An array item with Map children (106) [11]'); 64 | cy.findByText('An array item with Map children (103) [15]'); 65 | cy.findByText('An array item with Set children (104) [14]'); 66 | 67 | cy.findByTitle('Delete node 105').click(); 68 | cy.findByTitle('Delete node 106').click(); 69 | 70 | // Every type as a child of every other type 71 | cy.findByTitle('Add Object child to node 101').click(); 72 | cy.findByTitle('Add Array child to node 101').click(); 73 | cy.findByTitle('Add Map child to node 101').click(); 74 | cy.findByTitle('Add Set child to node 101').click(); 75 | cy.findByTitle('Add Object child to node 102').click(); 76 | cy.findByTitle('Add Array child to node 102').click(); 77 | cy.findByTitle('Add Map child to node 102').click(); 78 | cy.findByTitle('Add Set child to node 102').click(); 79 | cy.findByTitle('Add Object child to node 103').click(); 80 | cy.findByTitle('Add Array child to node 103').click(); 81 | cy.findByTitle('Add Map child to node 103').click(); 82 | cy.findByTitle('Add Set child to node 103').click(); 83 | cy.findByTitle('Add Object child to node 104').click(); 84 | cy.findByTitle('Add Array child to node 104').click(); 85 | cy.findByTitle('Add Map child to node 104').click(); 86 | cy.findByTitle('Add Set child to node 104').click(); 87 | 88 | // Sets can't be modified, so just the others 89 | cy.findByTitle('Turn node 101 on').click(); 90 | cy.findByTitle('Turn node 102 on').click(); 91 | cy.findByTitle('Turn node 103 on').click(); 92 | cy.findByTitle('Turn node 104 on').click(); 93 | cy.findByTitle('Turn node 107 on').click(); 94 | cy.findByTitle('Turn node 108 on').click(); 95 | cy.findByTitle('Turn node 109 on').click(); 96 | cy.findByTitle('Turn node 110 on').click(); 97 | cy.findByTitle('Turn node 111 on').click(); 98 | cy.findByTitle('Turn node 112 on').click(); 99 | cy.findByTitle('Turn node 113 on').click(); 100 | cy.findByTitle('Turn node 114 on').click(); 101 | cy.findByTitle('Turn node 115 on').click(); 102 | cy.findByTitle('Turn node 116 on').click(); 103 | cy.findByTitle('Turn node 117 on').click(); 104 | cy.findByTitle('Turn node 118 on').click(); 105 | 106 | cy.findByPlaceholderText('Notes for node 101').type('101!'); 107 | cy.findByPlaceholderText('Notes for node 102').type('102!'); 108 | cy.findByPlaceholderText('Notes for node 103').type('103!'); 109 | cy.findByPlaceholderText('Notes for node 104').type('104!'); 110 | cy.findByPlaceholderText('Notes for node 107').type('107!'); 111 | cy.findByPlaceholderText('Notes for node 108').type('108!'); 112 | cy.findByPlaceholderText('Notes for node 109').type('109!'); 113 | cy.findByPlaceholderText('Notes for node 110').type('110!'); 114 | cy.findByPlaceholderText('Notes for node 111').type('111!'); 115 | cy.findByPlaceholderText('Notes for node 112').type('112!'); 116 | cy.findByPlaceholderText('Notes for node 113').type('113!'); 117 | cy.findByPlaceholderText('Notes for node 114').type('114!'); 118 | cy.findByPlaceholderText('Notes for node 115').type('115!'); 119 | cy.findByPlaceholderText('Notes for node 116').type('116!'); 120 | cy.findByPlaceholderText('Notes for node 117').type('117!'); 121 | cy.findByPlaceholderText('Notes for node 118').type('118!'); 122 | 123 | cy.findByTitle('Delete node 107').click(); 124 | cy.findByTitle('Delete node 108').click(); 125 | cy.findByTitle('Delete node 109').click(); 126 | cy.findByTitle('Delete node 110').click(); 127 | cy.findByTitle('Delete node 111').click(); 128 | cy.findByTitle('Delete node 112').click(); 129 | cy.findByTitle('Delete node 113').click(); 130 | cy.findByTitle('Delete node 114').click(); 131 | cy.findByTitle('Delete node 115').click(); 132 | cy.findByTitle('Delete node 116').click(); 133 | cy.findByTitle('Delete node 117').click(); 134 | cy.findByTitle('Delete node 118').click(); 135 | 136 | // Delete the top 4 last 137 | cy.findByTitle('Delete node 101').click(); 138 | cy.findByTitle('Delete node 102').click(); 139 | cy.findByTitle('Delete node 103').click(); 140 | cy.findByTitle('Delete node 104').click(); 141 | 142 | cy.findByTitle('Turn node 100 off').click(); 143 | }); 144 | -------------------------------------------------------------------------------- /demo/cypress/integration/productPage.test.js: -------------------------------------------------------------------------------- 1 | it('Product page should work', () => { 2 | cy.visit('http://localhost:3000'); 3 | 4 | // Go to the Products page 5 | cy.contains('Products').click(); 6 | }); 7 | -------------------------------------------------------------------------------- /demo/cypress/integration/todoMvcPage.test.js: -------------------------------------------------------------------------------- 1 | it('Todo MVC page does the things it should do', () => { 2 | cy.visit('http://localhost:3000'); 3 | 4 | // Go to the TodoMVC page 5 | cy.contains('Todo MVC').click(); 6 | 7 | // Data from the API 8 | const task1Name = 'delectus aut autem'; 9 | const task2Name = 'quis ut nam facilis et officia qui'; 10 | const task3Name = 'fugiat veniam minus'; 11 | const task4Name = 'et porro tempora'; 12 | const task5Name = 13 | 'laboriosam mollitia et enim quasi adipisci quia provident illum'; 14 | 15 | cy.findByText(task1Name).should('exist'); 16 | cy.findByText(task2Name).should('exist'); 17 | cy.findByText(task3Name).should('exist'); 18 | cy.findByText(task4Name).should('exist'); 19 | cy.findByText(task5Name).should('exist'); 20 | 21 | cy.contains('4 items left'); 22 | 23 | cy.findByText('Active').click(); 24 | cy.findByText(task1Name).should('exist'); 25 | cy.findByText(task2Name).should('exist'); 26 | cy.findByText(task3Name).should('exist'); 27 | cy.findByText(task4Name).should('not.exist'); 28 | cy.findByText(task5Name).should('exist'); 29 | 30 | cy.findByText('Completed').click(); 31 | cy.findByText(task1Name).should('not.exist'); 32 | cy.findByText(task2Name).should('not.exist'); 33 | cy.findByText(task3Name).should('not.exist'); 34 | cy.findByText(task4Name).should('exist'); 35 | cy.findByText(task5Name).should('not.exist'); 36 | 37 | cy.findByText('All').click(); 38 | cy.findByText(task1Name).should('exist'); 39 | cy.findByText(task2Name).should('exist'); 40 | cy.findByText(task3Name).should('exist'); 41 | cy.findByText(task4Name).should('exist'); 42 | cy.findByText(task5Name).should('exist'); 43 | 44 | // Mark them all complete 45 | cy.findByTestId('toggle-all').click(); 46 | cy.contains('No items left'); 47 | 48 | // Mark them all incomplete 49 | cy.findByTestId('toggle-all').click(); 50 | cy.contains('5 items left'); 51 | 52 | // Mark them all complete again 53 | cy.findByTestId('toggle-all').click(); 54 | 55 | cy.findByText('Clear completed').click(); 56 | cy.findByText(task1Name).should('not.exist'); 57 | cy.findByText(task2Name).should('not.exist'); 58 | cy.findByText(task3Name).should('not.exist'); 59 | cy.findByText(task4Name).should('not.exist'); 60 | cy.findByText(task5Name).should('not.exist'); 61 | 62 | // Enter some new tasks 63 | cy.findByPlaceholderText('What needs to be done?').type('Task one{enter}'); 64 | // Auto focus seems not to work, so we select the input again 65 | cy.findByPlaceholderText('What needs to be done?').type('Task two{enter}'); 66 | 67 | cy.contains('2 items left'); 68 | 69 | // Enter nothing 70 | cy.findByPlaceholderText('What needs to be done?').type('{enter}'); 71 | cy.contains('2 items left'); 72 | 73 | // Finish task one 74 | cy.findByText('Task one').click(); 75 | cy.contains('1 item left'); 76 | 77 | // Edit task two 78 | cy.findByText('Task two').dblclick(); 79 | cy.findByDisplayValue('Task two').type( 80 | '{selectall}A new name for task two{enter}' 81 | ); 82 | 83 | // The delete button only shows on hover, so we need to force that 84 | cy.findByTitle(`Delete 'A new name for task two'`).invoke('show').click(); 85 | cy.findByText('A new name for task two').should('not.exist'); 86 | cy.findByTitle(`Delete 'Task one'`).invoke('show').click(); 87 | cy.findByText('Task one').should('not.exist'); 88 | }); 89 | -------------------------------------------------------------------------------- /demo/cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | // *********************************************************** 4 | // This example plugins/index.js can be used to load plugins 5 | // 6 | // You can change the location of this file or turn off loading 7 | // the plugins file with the 'pluginsFile' configuration option. 8 | // 9 | // You can read more here: 10 | // https://on.cypress.io/plugins-guide 11 | // *********************************************************** 12 | 13 | // This function is called when a project is opened or re-opened (e.g. due to 14 | // the project's config changing) 15 | 16 | /** 17 | * @type {Cypress.PluginConfig} 18 | */ 19 | module.exports = (on, config) => { 20 | // `on` is used to hook into various events Cypress emits 21 | // `config` is the resolved Cypress config 22 | }; 23 | -------------------------------------------------------------------------------- /demo/cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | import '@testing-library/cypress/add-commands'; 2 | -------------------------------------------------------------------------------- /demo/cypress/support/index.js: -------------------------------------------------------------------------------- 1 | import './commands'; 2 | -------------------------------------------------------------------------------- /demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "demo", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "build": "react-scripts build", 7 | "lint": "eslint src --fix --cache --max-warnings=0", 8 | "serve": "ws --compress --port=3000 --directory=./build", 9 | "serve:root": "ws --compress --port=3000 --directory=../", 10 | "start": "react-scripts start", 11 | "test": "cypress open" 12 | }, 13 | "browserslist": { 14 | "production": [ 15 | ">0.2%", 16 | "not dead", 17 | "not op_mini all" 18 | ], 19 | "development": [ 20 | "last 1 chrome version", 21 | "last 1 firefox version", 22 | "last 1 safari version" 23 | ] 24 | }, 25 | "eslintConfig": { 26 | "extends": "react-app" 27 | }, 28 | "dependencies": { 29 | "@material-ui/core": "4.9.7", 30 | "@material-ui/icons": "^4.9.1", 31 | "@material-ui/lab": "^4.0.0-alpha.46", 32 | "classnames": "2.2.6", 33 | "react": "16.13.1", 34 | "react-dom": "16.13.1", 35 | "react-recollect": "^5.2.2", 36 | "react-scripts": "3.4.1" 37 | }, 38 | "devDependencies": { 39 | "@testing-library/cypress": "^5.3.0", 40 | "@testing-library/jest-dom": "5.1.1", 41 | "@testing-library/react": "10.0.1", 42 | "@testing-library/user-event": "10.0.0", 43 | "@types/testing-library__cypress": "^5.0.3", 44 | "cypress": "^4.1.0", 45 | "eslint": "6.8.0", 46 | "eslint-config-airbnb": "18.1.0", 47 | "eslint-config-prettier": "6.10.1", 48 | "eslint-plugin-cypress": "^2.10.3", 49 | "eslint-plugin-import": "2.20.1", 50 | "eslint-plugin-prettier": "3.1.2", 51 | "local-web-server": "^4.0.0", 52 | "prettier": "2.0.1" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /demo/public/browser.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Recollect test 6 | 7 | 8 |
9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /demo/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidgilbertson/react-recollect/388e96311c19b136ed81810ffc856158053c19d5/demo/public/favicon.ico -------------------------------------------------------------------------------- /demo/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | React Recollect Demo Site 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /demo/src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | height: 100%; 3 | display: flex; 4 | flex-direction: column; 5 | } 6 | 7 | .App-header { 8 | padding: 8px; 9 | flex: none; 10 | background: #455a64; 11 | color: white; 12 | font-size: 24px; 13 | text-align: center; 14 | font-weight: 300; 15 | } 16 | 17 | .App__page-wrapper { 18 | flex: 1; 19 | } 20 | -------------------------------------------------------------------------------- /demo/src/App.js: -------------------------------------------------------------------------------- 1 | import AppBar from '@material-ui/core/AppBar'; 2 | import Box from '@material-ui/core/Box'; 3 | import ScopedCssBaseline from '@material-ui/core/ScopedCssBaseline'; 4 | import Tab from '@material-ui/core/Tab'; 5 | import Tabs from '@material-ui/core/Tabs'; 6 | import Toolbar from '@material-ui/core/Toolbar'; 7 | import Typography from '@material-ui/core/Typography'; 8 | import React from 'react'; 9 | import { collect } from 'react-recollect'; 10 | import './App.css'; 11 | import Updates from './pages/updates/Updates'; 12 | import BigTree from './pages/bigTree/BigTree'; 13 | import Products from './pages/products/Products'; 14 | import TodoMvcPage from './pages/todomvc/components/TodoMvcPage'; 15 | import { PAGES } from './shared/constants'; 16 | import StorePropType from './propTypes/StorePropType'; 17 | 18 | const App = ({ store }) => ( 19 |
20 | 21 | 22 | 23 | React Recollect demo site 24 | 25 | 26 | 27 | { 30 | store.currentPage = value; 31 | }} 32 | > 33 | {Object.values(PAGES).map((page) => ( 34 | 35 | ))} 36 | 37 | 38 | 39 | 40 | 41 | {store.currentPage === PAGES.UPDATES && } 42 | 43 | {store.currentPage === PAGES.PRODUCTS && } 44 | 45 | {store.currentPage === PAGES.BIG_TREE && ( 46 | 47 | 48 | 49 | )} 50 | 51 | {store.currentPage === PAGES.TODO_MVC && ( 52 | 53 | )} 54 |
55 | ); 56 | 57 | App.propTypes = { 58 | store: StorePropType.isRequired, 59 | }; 60 | 61 | export default collect(App); 62 | -------------------------------------------------------------------------------- /demo/src/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --gold--500: #ff9800; 3 | --gray--100: #f5f5f5; 4 | --gray--050: #fafafa; 5 | --gray--300: #e0e0e0; 6 | } 7 | 8 | html { 9 | box-sizing: border-box; 10 | } 11 | 12 | *, 13 | *::before, 14 | *::after { 15 | box-sizing: inherit; 16 | } 17 | 18 | html, 19 | body, 20 | #root { 21 | height: 100%; 22 | } 23 | 24 | body { 25 | margin: 0; 26 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 27 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 28 | sans-serif; 29 | -webkit-font-smoothing: antialiased; 30 | -moz-osx-font-smoothing: grayscale; 31 | overflow-y: scroll; 32 | background: var(--gray--050); 33 | } 34 | -------------------------------------------------------------------------------- /demo/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { afterChange, initStore } from 'react-recollect'; 4 | import App from './App'; 5 | import './index.css'; 6 | import { makeItem } from './pages/bigTree/utils'; 7 | import loadProducts from './pages/products/loadProducts'; 8 | import { 9 | LOAD_STATUSES, 10 | PAGES, 11 | PRODUCT_FILTER, 12 | TYPES, 13 | VISIBILITY_FILTERS, 14 | } from './shared/constants'; 15 | import Theme from './shared/Theme'; 16 | 17 | const currentPage = Object.values(PAGES).includes(localStorage.currentPage) 18 | ? localStorage.currentPage 19 | : PAGES.UPDATES; 20 | 21 | initStore({ 22 | currentPage, 23 | loading: false, 24 | productPage: { 25 | filter: PRODUCT_FILTER.ALL, 26 | products: [], 27 | searchQuery: '', 28 | }, 29 | batchUpdatePage: { 30 | text: '×', 31 | grid: { 32 | 100: { x: 0, y: 0 }, 33 | 101: { x: 0, y: 0 }, 34 | 102: { x: 0, y: 0 }, 35 | 103: { x: 0, y: 0 }, 36 | 104: { x: 0, y: 0 }, 37 | 105: { x: 0, y: 0 }, 38 | 106: { x: 0, y: 0 }, 39 | 107: { x: 0, y: 0 }, 40 | 108: { x: 0, y: 0 }, 41 | 109: { x: 0, y: 0 }, 42 | 110: { x: 0, y: 0 }, 43 | 111: { x: 0, y: 0 }, 44 | 112: { x: 0, y: 0 }, 45 | 113: { x: 0, y: 0 }, 46 | 114: { x: 0, y: 0 }, 47 | 115: { x: 0, y: 0 }, 48 | }, 49 | }, 50 | todoMvcPage: { 51 | loadStatus: LOAD_STATUSES.NOT_STARTED, 52 | todos: [], 53 | visibilityFilter: VISIBILITY_FILTERS.SHOW_ALL, 54 | }, 55 | bigTreePage: { 56 | tree: makeItem(TYPES.OBJ, TYPES.ARR), 57 | expandedNodeIds: new Set(), 58 | }, 59 | }); 60 | 61 | loadProducts(); 62 | 63 | afterChange((e) => { 64 | // When the currentPage changes, update localStorage 65 | if (e.changedProps.includes('currentPage')) { 66 | localStorage.currentPage = e.store.currentPage; 67 | } 68 | }); 69 | 70 | ReactDOM.render( 71 | 72 | 73 | , 74 | document.getElementById('root') 75 | ); 76 | -------------------------------------------------------------------------------- /demo/src/pages/bigTree/BigTree.js: -------------------------------------------------------------------------------- 1 | import Box from '@material-ui/core/Box'; 2 | import Container from '@material-ui/core/Container'; 3 | import Typography from '@material-ui/core/Typography'; 4 | import ChevronRightIcon from '@material-ui/icons/ChevronRight'; 5 | import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; 6 | import TreeView from '@material-ui/lab/TreeView'; 7 | import React from 'react'; 8 | import { collect } from 'react-recollect'; 9 | import StorePropType from '../../propTypes/StorePropType'; 10 | import Item from './Item'; 11 | import { stringifyPlus } from './utils'; 12 | 13 | const BigTree = (props) => { 14 | const { expandedNodeIds, tree } = props.store.bigTreePage; 15 | 16 | return ( 17 | 18 | 19 | 20 | This page is for internal testing and won‘t make a lot of sense to the 21 | casual observer. 22 | 23 | 24 | 25 | } 27 | defaultExpandIcon={} 28 | expanded={Array.from(expandedNodeIds)} 29 | > 30 | 31 | 32 | 33 |
34 |
40 |         {stringifyPlus(tree)}
41 |       
42 |
43 | ); 44 | }; 45 | 46 | BigTree.propTypes = { 47 | store: StorePropType.isRequired, 48 | }; 49 | 50 | export default collect(BigTree); 51 | -------------------------------------------------------------------------------- /demo/src/pages/bigTree/Item.js: -------------------------------------------------------------------------------- 1 | import Box from '@material-ui/core/Box'; 2 | import Button from '@material-ui/core/Button'; 3 | import ButtonGroup from '@material-ui/core/ButtonGroup'; 4 | import IconButton from '@material-ui/core/IconButton'; 5 | import Switch from '@material-ui/core/Switch'; 6 | import { Delete } from '@material-ui/icons'; 7 | import TreeItem from '@material-ui/lab/TreeItem'; 8 | import React, { useRef } from 'react'; 9 | import { batch, PropTypes } from 'react-recollect'; 10 | import { TYPES } from '../../shared/constants'; 11 | import { getChildrenAsArray } from './selectors'; 12 | import { deleteChild } from './updaters'; 13 | import { makeItem } from './utils'; 14 | 15 | const Item = (props) => { 16 | const { item, parent } = props; 17 | 18 | // Items will re-render when the item updates or the parent item updates. 19 | const renderCountRef = useRef(0); 20 | renderCountRef.current++; 21 | 22 | const nodeId = item.id.toString(); 23 | const isSetItem = item.parentType === TYPES.SET; 24 | 25 | const addChild = (newItem) => { 26 | if (item.childrenType === TYPES.OBJ) { 27 | item.children[newItem.id] = newItem; 28 | } else if (item.childrenType === TYPES.ARR) { 29 | item.children.push(newItem); 30 | } else if (item.childrenType === TYPES.MAP) { 31 | item.children.set(newItem.id, newItem); 32 | } else if (item.childrenType === TYPES.SET) { 33 | item.children.add(newItem); 34 | } 35 | }; 36 | 37 | return ( 38 | 42 | 43 |
{ 48 | // Don't expand/collapse if there's nothing to expand/collapse 49 | if (item.children) { 50 | if (props.expandedNodeIds.has(nodeId)) { 51 | props.expandedNodeIds.delete(nodeId); 52 | } else { 53 | props.expandedNodeIds.add(nodeId); 54 | } 55 | } 56 | }} 57 | > 58 | {props.item.name} [{renderCountRef.current}] 59 |
60 |
61 | 62 | 63 | 64 | {Object.entries(TYPES).map(([typeCode, typeString]) => ( 65 | 80 | ))} 81 | 82 | 83 | { 90 | props.item.switchedOn = e.target.checked; 91 | }} 92 | /> 93 | 94 | { 107 | props.item.notes = e.target.value; 108 | }} 109 | onClick={(e) => { 110 | // material-ui is stealing focus after the input is clicked. 111 | // This fix is extremely dodgy, 112 | // but for a test site it's good enough 113 | e.persist(); 114 | setTimeout(() => { 115 | e.target.focus(); 116 | }); 117 | }} 118 | /> 119 | 120 | props.onDeleteChild(parent, item)} 123 | title={`Delete node ${nodeId}`} 124 | > 125 | 126 | 127 | 128 | 129 | } 130 | > 131 | {item.children && 132 | getChildrenAsArray(item).map((child) => ( 133 | 140 | ))} 141 |
142 | ); 143 | }; 144 | 145 | const ItemPropType = PropTypes.shape({ 146 | id: PropTypes.number.isRequired, 147 | name: PropTypes.string.isRequired, 148 | childrenType: PropTypes.oneOf(Object.values(TYPES)).isRequired, 149 | parentType: PropTypes.oneOf(Object.values(TYPES)).isRequired, 150 | children: PropTypes.any.isRequired, 151 | switchedOn: PropTypes.bool.isRequired, 152 | notes: PropTypes.string.isRequired, 153 | }); 154 | 155 | Item.propTypes = { 156 | item: ItemPropType.isRequired, 157 | parent: ItemPropType, 158 | onDeleteChild: PropTypes.func, 159 | expandedNodeIds: PropTypes.instanceOf(Set).isRequired, 160 | }; 161 | 162 | const ItemMemo = React.memo(Item); 163 | 164 | export default Item; 165 | -------------------------------------------------------------------------------- /demo/src/pages/bigTree/selectors.js: -------------------------------------------------------------------------------- 1 | import { TYPES } from '../../shared/constants'; 2 | 3 | export const getChildrenAsArray = (item) => { 4 | if (item.childrenType === TYPES.OBJ) { 5 | return Array.from(Object.values(item.children)); 6 | } 7 | if (item.childrenType === TYPES.ARR) { 8 | return item.children; 9 | } 10 | if (item.childrenType === TYPES.MAP) { 11 | return Array.from(item.children.values()); 12 | } 13 | if (item.childrenType === TYPES.SET) { 14 | return Array.from(item.children); 15 | } 16 | 17 | throw Error('Unknown type'); 18 | }; 19 | -------------------------------------------------------------------------------- /demo/src/pages/bigTree/updaters.js: -------------------------------------------------------------------------------- 1 | import { TYPES } from '../../shared/constants'; 2 | 3 | export const deleteChild = (parent, child) => { 4 | if (parent.childrenType === TYPES.OBJ) { 5 | delete parent.children[child.id]; 6 | } else if (parent.childrenType === TYPES.ARR) { 7 | parent.children = parent.children.filter( 8 | (childItem) => childItem.id !== child.id 9 | ); 10 | } else if (parent.childrenType === TYPES.MAP) { 11 | parent.children.delete(child.id); 12 | } else if (parent.childrenType === TYPES.SET) { 13 | parent.children.delete(child); 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /demo/src/pages/bigTree/utils.js: -------------------------------------------------------------------------------- 1 | import { TYPES } from '../../shared/constants'; 2 | 3 | let id = 99; 4 | 5 | export const makeItem = (parentType, childrenType) => { 6 | id++; 7 | 8 | const item = { 9 | id, 10 | name: null, 11 | childrenType, 12 | parentType, 13 | switchedOn: false, 14 | notes: '', 15 | }; 16 | 17 | if (parentType === TYPES.OBJ) { 18 | item.name = `An object prop with ${childrenType} children (${id})`; 19 | } else if (parentType === TYPES.ARR) { 20 | item.name = `An array item with ${childrenType} children (${id})`; 21 | } else if (parentType === TYPES.MAP) { 22 | item.name = `A Map entry with ${childrenType} children (${id})`; 23 | } else if (parentType === TYPES.SET) { 24 | item.name = `A Set entry with ${childrenType} children (${id})`; 25 | } 26 | 27 | if (childrenType === TYPES.OBJ) { 28 | item.children = {}; 29 | } else if (childrenType === TYPES.ARR) { 30 | item.children = []; 31 | } else if (childrenType === TYPES.MAP) { 32 | item.children = new Map(); 33 | } else if (childrenType === TYPES.SET) { 34 | item.children = new Set(); 35 | } 36 | 37 | return item; 38 | }; 39 | 40 | export const stringifyPlus = (data) => 41 | JSON.stringify( 42 | data, 43 | (key, value) => { 44 | if (value instanceof Map) { 45 | return { 46 | '': Array.from(value), 47 | }; 48 | } 49 | 50 | if (value instanceof Set) { 51 | return { 52 | '': Array.from(value), 53 | }; 54 | } 55 | 56 | return value; 57 | }, 58 | 2 59 | ); 60 | -------------------------------------------------------------------------------- /demo/src/pages/products/Product.js: -------------------------------------------------------------------------------- 1 | import Card from '@material-ui/core/Card'; 2 | import CardContent from '@material-ui/core/CardContent'; 3 | import Typography from '@material-ui/core/Typography'; 4 | import React from 'react'; 5 | import styles from './Product.module.css'; 6 | import ProductPropType from './propTypes/ProductPropType'; 7 | 8 | const formatDate = (dateAsNum) => { 9 | const date = new Date(dateAsNum); 10 | return date.toLocaleString('en', { 11 | year: 'numeric', 12 | month: 'short', 13 | day: 'numeric', 14 | }); 15 | }; 16 | 17 | const formatPrice = (priceAsNum) => `$${priceAsNum.toFixed(2)}`; 18 | 19 | const Product = ({ product }) => { 20 | return ( 21 | 22 | 23 | 24 | {product.name} 25 | 26 | 27 | 28 | {formatPrice(product.price)} 29 | 30 | 31 | 32 | {product.description} 33 | 34 | 35 |
36 | 37 | {product.category} 38 | 39 | 40 | {formatDate(product.date)} 41 | 42 | 60 |
61 |
62 |
63 | ); 64 | }; 65 | 66 | Product.propTypes = { 67 | product: ProductPropType.isRequired, 68 | }; 69 | 70 | export default React.memo(Product); 71 | -------------------------------------------------------------------------------- /demo/src/pages/products/Product.module.css: -------------------------------------------------------------------------------- 1 | .panel { 2 | margin-top: 24px; 3 | height: 300px; 4 | display: flex; 5 | } 6 | 7 | .image { 8 | width: 300px; 9 | flex: none; 10 | height: 300px; 11 | } 12 | 13 | .cardContent { 14 | display: flex; 15 | flex-direction: column; 16 | } 17 | 18 | .description { 19 | overflow: hidden; 20 | } 21 | 22 | .details { 23 | display: flex; 24 | align-items: center; 25 | margin-top: auto; 26 | padding-top: 16px; 27 | justify-content: space-between; 28 | } 29 | 30 | .starButton { 31 | border: none; 32 | width: 32px; 33 | height: 32px; 34 | display: flex; 35 | align-items: center; 36 | justify-content: center; 37 | background: transparent; 38 | cursor: pointer; 39 | } 40 | 41 | .starButton:focus { 42 | outline: none; 43 | } 44 | 45 | .starOn:hover, 46 | .starOn { 47 | fill: var(--gold--500); 48 | } 49 | 50 | .starOff { 51 | fill: var(--gray--300); 52 | } 53 | -------------------------------------------------------------------------------- /demo/src/pages/products/Products.js: -------------------------------------------------------------------------------- 1 | import Button from '@material-ui/core/Button'; 2 | import CircularProgress from '@material-ui/core/CircularProgress'; 3 | import Grid from '@material-ui/core/Grid'; 4 | import MenuItem from '@material-ui/core/MenuItem'; 5 | import Paper from '@material-ui/core/Paper'; 6 | import Select from '@material-ui/core/Select'; 7 | import TextField from '@material-ui/core/TextField'; 8 | import Typography from '@material-ui/core/Typography'; 9 | import React from 'react'; 10 | import { collect } from 'react-recollect'; 11 | import StorePropType from '../../propTypes/StorePropType'; 12 | import { PRODUCT_FILTER } from '../../shared/constants'; 13 | import Product from './Product'; 14 | import styles from './Products.module.css'; 15 | 16 | const getVisibleProducts = ({ products, filter, searchQuery }) => { 17 | const filteredProducts = 18 | filter === PRODUCT_FILTER.ALL 19 | ? products 20 | : products.filter((product) => product.favorite); 21 | 22 | return searchQuery 23 | ? filteredProducts.filter( 24 | (product) => 25 | product.name.toLowerCase().includes(searchQuery.toLowerCase()) || 26 | product.category.toLowerCase().includes(searchQuery.toLowerCase()) || 27 | product.description.toLowerCase().includes(searchQuery.toLowerCase()) 28 | ) 29 | : filteredProducts; 30 | }; 31 | 32 | const Products = (props) => { 33 | const data = props.store.productPage; 34 | 35 | if (props.store.loading) { 36 | return ( 37 |
38 | 39 |
40 | ); 41 | } 42 | 43 | const searchResults = getVisibleProducts({ 44 | products: data.products, 45 | filter: data.filter, 46 | searchQuery: data.searchQuery, 47 | }); 48 | 49 | return ( 50 |
51 | 52 | 53 | 54 | { 66 | data.searchQuery = e.target.value; 67 | }} 68 | /> 69 | 70 | 71 | 72 | 85 | 86 | 87 | 88 | 100 | 101 | 102 | 103 | 104 | 109 | {data.products.length !== searchResults.length && ( 110 | <> 111 | Showing {searchResults.length} result 112 | {searchResults.length === 1 ? '' : 's'} 113 | 114 | )} 115 | 116 | 117 | {searchResults && 118 | searchResults.map((product) => ( 119 | 120 | ))} 121 |
122 | ); 123 | }; 124 | 125 | Products.propTypes = { 126 | store: StorePropType.isRequired, 127 | }; 128 | 129 | export default collect(Products); 130 | -------------------------------------------------------------------------------- /demo/src/pages/products/Products.module.css: -------------------------------------------------------------------------------- 1 | .loading { 2 | text-align: center; 3 | padding-top: 20vh; 4 | } 5 | 6 | .wrapper { 7 | width: 90%; 8 | max-width: 1000px; 9 | margin: 48px auto; 10 | } 11 | 12 | .refinement { 13 | padding: 0 16px; 14 | } 15 | 16 | .filter { 17 | width: 100%; 18 | } 19 | 20 | .resultCount { 21 | height: 32px; 22 | padding-top: 24px; 23 | } 24 | 25 | .productList { 26 | margin-top: 64px; 27 | } 28 | -------------------------------------------------------------------------------- /demo/src/pages/products/loadProducts.js: -------------------------------------------------------------------------------- 1 | import { store } from 'react-recollect'; 2 | import makeData from './makeData'; 3 | 4 | /** @return void */ 5 | const loadProducts = async () => { 6 | store.loading = true; 7 | 8 | store.productPage.products = await makeData('/api/blah'); 9 | 10 | store.loading = false; 11 | }; 12 | 13 | export default loadProducts; 14 | -------------------------------------------------------------------------------- /demo/src/pages/products/makeData.js: -------------------------------------------------------------------------------- 1 | const PRODUCT_COUNT = 103; 2 | 3 | const lorems = [ 4 | 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Mauris sem tellus, sagittis ac est' + 5 | ' ac, faucibus finibus mi. Donec scelerisque nibh nibh, accumsan feugiat elit gravida ut. Etiam a condimentum ex. Nunc feugiat varius rhoncus. Proin molestie sollicitudin eros pharetra euismod. Morbi nec dignissim ex, eu egestas quam. Nunc molestie molestie laoreet. Nam metus leo, auctor non sem id, lobortis blandit nisl. Nulla non neque quis elit consectetur vulputate ultrices vel arcu.', 6 | 'Donec ac dignissim lacus. Nulla egestas et ligula vitae tristique. Duis viverra mattis nisi, in vulputate magna consectetur at. Praesent hendrerit nulla eget ante luctus hendrerit. Donec id consequat nibh. Duis at lorem arcu. Mauris iaculis est tortor, sed rutrum eros congue sit amet. Integer fringilla mauris est, at condimentum massa finibus sed. Suspendisse potenti.', 7 | 'Pellentesque et purus lacus. Mauris lobortis quam nec aliquet hendrerit. Mauris lacus erat, aliquam ac augue in, vulputate hendrerit nulla. Maecenas lobortis, arcu ac scelerisque laoreet, ligula sem aliquet turpis, quis tempus odio nulla tempor diam. Aenean ex nulla, ultrices tempus arcu at, pellentesque rhoncus mauris. In aliquam vel ex vel ultrices. Morbi posuere tincidunt vehicula. Ut interdum mauris ut rhoncus vulputate. Vivamus efficitur placerat sem, in elementum turpis eleifend non. Proin sit amet ipsum sit amet augue pretium blandit et non ex. Aenean lacus magna, volutpat id lacinia id, fermentum et velit. Aenean dignissim tortor sit amet volutpat venenatis. Nam dui est, fringilla vitae mollis sit amet, fermentum eget odio. Fusce efficitur luctus odio, ut bibendum lorem dignissim et.', 8 | 'Curabitur vestibulum purus non mollis volutpat. Nunc lorem ex, porta ut vehicula ultricies, facilisis ac tellus. Quisque eu magna neque. Aliquam non justo faucibus, luctus odio at, pharetra massa. Mauris ultricies venenatis enim, pulvinar pharetra justo. Curabitur rutrum venenatis nunc. Suspendisse fringilla eu odio quis posuere. Curabitur semper ligula ac facilisis consectetur. Fusce bibendum consectetur vehicula. Mauris tellus mauris, iaculis volutpat tortor in, molestie iaculis ipsum. Lorem ipsum dolor sit amet, consectetur adipiscing elit.', 9 | 'Nullam euismod nulla sed congue sagittis. Integer ac imperdiet arcu, laoreet sagittis justo. Vestibulum vestibulum velit sit amet nulla lacinia, at viverra libero malesuada. Vestibulum vitae eleifend nibh. Suspendisse nunc purus, vulputate in scelerisque ut, accumsan id lacus. Ut quis nulla nulla. Fusce efficitur turpis nunc, ut auctor ipsum iaculis vitae. Maecenas volutpat nisi vel elit vulputate, iaculis elementum diam pulvinar. Cras sit amet porttitor lectus. Quisque faucibus mollis orci vitae iaculis. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos.', 10 | ]; 11 | 12 | const productCategory = [ 13 | 'abstract', 14 | 'animals', 15 | 'business', 16 | 'cats', 17 | 'city', 18 | 'food', 19 | 'nightlife', 20 | 'fashion', 21 | 'people', 22 | 'nature', 23 | 'sports', 24 | 'technics', 25 | 'transport', 26 | ]; 27 | 28 | const A = [ 29 | 'Pretty', 30 | 'Large', 31 | 'Big', 32 | 'Small', 33 | 'Tall', 34 | 'Short', 35 | 'Long', 36 | 'Handsome', 37 | 'Plain', 38 | 'Quaint', 39 | 'Clean', 40 | 'Elegant', 41 | 'Easy', 42 | 'Angry', 43 | 'Crazy', 44 | 'Helpful', 45 | 'Mushy', 46 | 'Odd', 47 | 'Unsightly', 48 | 'Adorable', 49 | 'Important', 50 | 'Inexpensive', 51 | 'Cheap', 52 | 'Expensive', 53 | 'Fancy', 54 | ]; 55 | const C = [ 56 | 'red', 57 | 'yellow', 58 | 'blue', 59 | 'green', 60 | 'pink', 61 | 'brown', 62 | 'purple', 63 | 'brown', 64 | 'white', 65 | 'black', 66 | 'orange', 67 | ]; 68 | const N = [ 69 | 'table', 70 | 'chair', 71 | 'house', 72 | 'bbq', 73 | 'desk', 74 | 'car', 75 | 'pony', 76 | 'cookie', 77 | 'sandwich', 78 | 'burger', 79 | 'pizza', 80 | 'mouse', 81 | 'keyboard', 82 | ]; 83 | 84 | const pickOne = (arr) => arr[Math.floor(Math.random() * arr.length)]; 85 | 86 | const getFakeName = () => `${pickOne(A)} ${pickOne(C)} ${pickOne(N)}`; 87 | 88 | const products = Array(PRODUCT_COUNT) 89 | .fill(null) 90 | .map((x, i) => ({ 91 | id: i, 92 | name: getFakeName(), 93 | price: Math.random() * 1000, 94 | description: pickOne(lorems), 95 | category: pickOne(productCategory), 96 | date: new Date(Date.now() - Math.random() * 10000000000), 97 | favorite: Boolean(i % 2), 98 | })); 99 | 100 | const makeData = () => 101 | new Promise((resolve) => { 102 | setTimeout(() => { 103 | resolve(products); 104 | }, 100); 105 | }); 106 | 107 | export default makeData; 108 | -------------------------------------------------------------------------------- /demo/src/pages/products/propTypes/ProductPagePropType.js: -------------------------------------------------------------------------------- 1 | import { PropTypes } from 'react-recollect'; 2 | import { PRODUCT_FILTER } from '../../../shared/constants'; 3 | import ProductPropType from './ProductPropType'; 4 | 5 | const ProductPagePropType = PropTypes.shape({ 6 | filter: PropTypes.oneOf(Object.values(PRODUCT_FILTER)).isRequired, 7 | products: PropTypes.arrayOf(ProductPropType), 8 | searchQuery: PropTypes.string.isRequired, 9 | }); 10 | 11 | export default ProductPagePropType; 12 | -------------------------------------------------------------------------------- /demo/src/pages/products/propTypes/ProductPropType.js: -------------------------------------------------------------------------------- 1 | import { PropTypes } from 'react-recollect'; 2 | 3 | const ProductPropType = PropTypes.shape({ 4 | id: PropTypes.number.isRequired, 5 | name: PropTypes.string.isRequired, 6 | price: PropTypes.number.isRequired, 7 | description: PropTypes.string.isRequired, 8 | category: PropTypes.string.isRequired, 9 | date: PropTypes.instanceOf(Date).isRequired, 10 | favorite: PropTypes.bool.isRequired, 11 | }); 12 | 13 | export default ProductPropType; 14 | -------------------------------------------------------------------------------- /demo/src/pages/todomvc/components/Footer.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { collect, PropTypes } from 'react-recollect'; 3 | import StorePropType from '../../../propTypes/StorePropType'; 4 | import { VISIBILITY_FILTERS } from '../../../shared/constants'; 5 | import Link from './Link'; 6 | 7 | const Footer = (props) => { 8 | const { activeCount, completedCount, onClearCompleted } = props; 9 | const itemWord = activeCount === 1 ? 'item' : 'items'; 10 | 11 | return ( 12 |
13 | 14 | {activeCount || 'No'} {itemWord} left 15 | 16 | 17 |
    18 | {Object.values(VISIBILITY_FILTERS).map((filter) => ( 19 |
  • 20 | 24 | {filter} 25 | 26 |
  • 27 | ))} 28 |
29 | 30 | {!!completedCount && ( 31 | 34 | )} 35 |
36 | ); 37 | }; 38 | 39 | Footer.propTypes = { 40 | store: StorePropType.isRequired, 41 | completedCount: PropTypes.number.isRequired, 42 | activeCount: PropTypes.number.isRequired, 43 | onClearCompleted: PropTypes.func.isRequired, 44 | }; 45 | 46 | export default collect(Footer); 47 | -------------------------------------------------------------------------------- /demo/src/pages/todomvc/components/Header.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { collect } from 'react-recollect'; 3 | import StorePropType from '../../../propTypes/StorePropType'; 4 | import TodoTextInput from './TodoTextInput'; 5 | 6 | const Header = ({ store }) => ( 7 |
8 |

todos

9 | 10 | { 13 | if (title.length) { 14 | store.todoMvcPage.todos.push({ 15 | id: Math.random(), 16 | title, 17 | completed: false, 18 | }); 19 | } 20 | }} 21 | placeholder="What needs to be done?" 22 | /> 23 |
24 | ); 25 | 26 | Header.propTypes = { 27 | store: StorePropType.isRequired, 28 | }; 29 | 30 | export default collect(Header); 31 | -------------------------------------------------------------------------------- /demo/src/pages/todomvc/components/Link.js: -------------------------------------------------------------------------------- 1 | import classnames from 'classnames'; 2 | import React from 'react'; 3 | import { collect, PropTypes } from 'react-recollect'; 4 | import StorePropType from '../../../propTypes/StorePropType'; 5 | import { VISIBILITY_FILTERS } from '../../../shared/constants'; 6 | 7 | const Link = ({ filter, children, store }) => ( 8 | 18 | ); 19 | 20 | Link.propTypes = { 21 | store: StorePropType.isRequired, 22 | filter: PropTypes.oneOf(Object.values(VISIBILITY_FILTERS)).isRequired, 23 | children: PropTypes.node.isRequired, 24 | }; 25 | 26 | export default collect(Link); 27 | -------------------------------------------------------------------------------- /demo/src/pages/todomvc/components/MainSection.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { collect } from 'react-recollect'; 3 | import StorePropType from '../../../propTypes/StorePropType'; 4 | import Footer from './Footer'; 5 | import TodoList from './TodoList'; 6 | 7 | const MainSection = ({ store }) => { 8 | const completedCount = store.todoMvcPage.todos.filter( 9 | (todo) => todo.completed 10 | ).length; 11 | const todosCount = store.todoMvcPage.todos.length; 12 | 13 | return ( 14 |
15 | {!!todosCount && ( 16 | 17 | 23 | 36 | )} 37 | 38 | 39 | 40 | {!!todosCount && ( 41 |
{ 45 | store.todoMvcPage.todos = store.todoMvcPage.todos.filter( 46 | (todo) => todo.completed === false 47 | ); 48 | }} 49 | /> 50 | )} 51 |
52 | ); 53 | }; 54 | 55 | MainSection.propTypes = { 56 | store: StorePropType.isRequired, 57 | }; 58 | 59 | export default collect(MainSection); 60 | -------------------------------------------------------------------------------- /demo/src/pages/todomvc/components/TodoItem.js: -------------------------------------------------------------------------------- 1 | import classnames from 'classnames'; 2 | import React, { useState } from 'react'; 3 | import TodoPropType from '../propTypes/TodoPropType'; 4 | import deleteTodo from '../updaters/deleteTodo'; 5 | import TodoTextInput from './TodoTextInput'; 6 | 7 | const TodoItem = ({ todo }) => { 8 | const [editing, setEditing] = useState(false); 9 | 10 | const handleSave = (id, title) => { 11 | if (!title.length) { 12 | deleteTodo(id); 13 | } else { 14 | todo.title = title; 15 | } 16 | 17 | setEditing(false); 18 | }; 19 | 20 | const inputId = `todo-complete-${todo.id}`; 21 | 22 | return ( 23 |
  • 29 | {editing ? ( 30 | handleSave(todo.id, title)} 34 | /> 35 | ) : ( 36 |
    37 | { 43 | todo.completed = !todo.completed; 44 | }} 45 | /> 46 | 47 | 55 | 56 |
    62 | )} 63 |
  • 64 | ); 65 | }; 66 | 67 | TodoItem.propTypes = { 68 | todo: TodoPropType.isRequired, 69 | }; 70 | 71 | export default React.memo(TodoItem); 72 | -------------------------------------------------------------------------------- /demo/src/pages/todomvc/components/TodoList.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { collect } from 'react-recollect'; 3 | import getVisibleTodos from '../selectors/getVisibleTodos'; 4 | import TodoItem from './TodoItem'; 5 | 6 | const TodoList = () => ( 7 |
      8 | {getVisibleTodos().map((todo) => ( 9 | 10 | ))} 11 |
    12 | ); 13 | 14 | export default collect(TodoList); 15 | -------------------------------------------------------------------------------- /demo/src/pages/todomvc/components/TodoMvcPage.js: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import React, { useEffect } from 'react'; 3 | import { collect, PropTypes } from 'react-recollect'; 4 | import StorePropType from '../../../propTypes/StorePropType'; 5 | import { LOAD_STATUSES } from '../../../shared/constants'; 6 | import loadTodoData from '../selectors/loadTodoData'; 7 | import Header from './Header'; 8 | import MainSection from './MainSection'; 9 | import './TodoMvcPage.css'; 10 | 11 | const TodoMvcPage = (props) => { 12 | const { loadStatus } = props.store.todoMvcPage; 13 | 14 | useEffect(() => { 15 | if (loadStatus === LOAD_STATUSES.NOT_STARTED) loadTodoData(); 16 | }, [loadStatus]); 17 | 18 | return ( 19 |
    20 |
    21 |
    22 | 23 | {props.store.todoMvcPage.loadStatus === LOAD_STATUSES.LOADING ? ( 24 |

    Loading...

    25 | ) : ( 26 | 27 | )} 28 |
    29 |
    30 | ); 31 | }; 32 | 33 | TodoMvcPage.propTypes = { 34 | className: PropTypes.string.isRequired, 35 | store: StorePropType.isRequired, 36 | }; 37 | 38 | export default collect(TodoMvcPage); 39 | -------------------------------------------------------------------------------- /demo/src/pages/todomvc/components/TodoTextInput.js: -------------------------------------------------------------------------------- 1 | import classnames from 'classnames'; 2 | import React, { useState } from 'react'; 3 | import { PropTypes } from 'react-recollect'; 4 | 5 | const TodoTextInput = (props) => { 6 | const [inputText, setInputText] = useState(props.title || ''); 7 | 8 | const handleSubmit = (e) => { 9 | const title = e.target.value.trim(); 10 | 11 | // 13: Enter 12 | if (e.which === 13) { 13 | props.onSave(title); 14 | 15 | if (props.newTodo) setInputText(''); 16 | } 17 | }; 18 | 19 | const handleChange = (e) => { 20 | setInputText(e.target.value); 21 | }; 22 | 23 | const handleBlur = (e) => { 24 | if (!props.newTodo) props.onSave(e.target.value); 25 | }; 26 | 27 | return ( 28 | 40 | ); 41 | }; 42 | 43 | TodoTextInput.propTypes = { 44 | onSave: PropTypes.func.isRequired, 45 | title: PropTypes.string, 46 | placeholder: PropTypes.string, 47 | editing: PropTypes.bool, 48 | newTodo: PropTypes.bool, 49 | }; 50 | 51 | TodoTextInput.defaultProps = { 52 | title: '', 53 | placeholder: '', 54 | editing: false, 55 | newTodo: false, 56 | }; 57 | 58 | export default React.memo(TodoTextInput); 59 | -------------------------------------------------------------------------------- /demo/src/pages/todomvc/propTypes/TodoMvcPropType.js: -------------------------------------------------------------------------------- 1 | import { PropTypes } from 'react-recollect'; 2 | import { VISIBILITY_FILTERS } from '../../../shared/constants'; 3 | import TodoPropType from './TodoPropType'; 4 | 5 | const TodoMvcPropType = PropTypes.shape({ 6 | todos: PropTypes.arrayOf(TodoPropType).isRequired, 7 | visibilityFilter: PropTypes.oneOf(Object.values(VISIBILITY_FILTERS)), 8 | }); 9 | 10 | export default TodoMvcPropType; 11 | -------------------------------------------------------------------------------- /demo/src/pages/todomvc/propTypes/TodoPropType.js: -------------------------------------------------------------------------------- 1 | import { PropTypes } from 'react-recollect'; 2 | 3 | const TodoPropType = PropTypes.shape({ 4 | id: PropTypes.number.isRequired, 5 | title: PropTypes.string.isRequired, 6 | completed: PropTypes.bool.isRequired, 7 | }); 8 | 9 | export default TodoPropType; 10 | -------------------------------------------------------------------------------- /demo/src/pages/todomvc/selectors/getVisibleTodos.js: -------------------------------------------------------------------------------- 1 | import { store } from 'react-recollect'; 2 | import { VISIBILITY_FILTERS } from '../../../shared/constants'; 3 | 4 | const getVisibleTodos = () => { 5 | const { todos, visibilityFilter } = store.todoMvcPage; 6 | 7 | if (visibilityFilter === VISIBILITY_FILTERS.SHOW_ALL) { 8 | return todos; 9 | } 10 | 11 | if (visibilityFilter === VISIBILITY_FILTERS.SHOW_COMPLETED) { 12 | return todos.filter((todo) => todo.completed); 13 | } 14 | 15 | if (visibilityFilter === VISIBILITY_FILTERS.SHOW_ACTIVE) { 16 | return todos.filter((todo) => !todo.completed); 17 | } 18 | 19 | throw new Error(`Unknown filter: ${visibilityFilter}`); 20 | }; 21 | 22 | export default getVisibleTodos; 23 | -------------------------------------------------------------------------------- /demo/src/pages/todomvc/selectors/loadTodoData.js: -------------------------------------------------------------------------------- 1 | import { store } from 'react-recollect'; 2 | import { LOAD_STATUSES } from '../../../shared/constants'; 3 | 4 | /** @return void */ 5 | const loadTodoData = () => { 6 | store.todoMvcPage.loadStatus = LOAD_STATUSES.LOADING; 7 | 8 | // Simulate async fetching 9 | setTimeout(() => { 10 | store.todoMvcPage.todos = [ 11 | { 12 | userId: 1, 13 | id: 1, 14 | title: 'delectus aut autem', 15 | completed: false, 16 | }, 17 | { 18 | userId: 1, 19 | id: 2, 20 | title: 'quis ut nam facilis et officia qui', 21 | completed: false, 22 | }, 23 | { 24 | userId: 1, 25 | id: 3, 26 | title: 'fugiat veniam minus', 27 | completed: false, 28 | }, 29 | { 30 | userId: 1, 31 | id: 4, 32 | title: 'et porro tempora', 33 | completed: true, 34 | }, 35 | { 36 | userId: 1, 37 | id: 5, 38 | title: 39 | 'laboriosam mollitia et enim quasi adipisci quia provident illum', 40 | completed: false, 41 | }, 42 | ]; 43 | 44 | store.todoMvcPage.loadStatus = LOAD_STATUSES.LOADED; 45 | }, 50); 46 | }; 47 | 48 | export default loadTodoData; 49 | -------------------------------------------------------------------------------- /demo/src/pages/todomvc/updaters/deleteTodo.js: -------------------------------------------------------------------------------- 1 | import { store } from 'react-recollect'; 2 | 3 | const deleteTodo = (id) => { 4 | store.todoMvcPage.todos = store.todoMvcPage.todos.filter( 5 | (todo) => todo.id !== id 6 | ); 7 | }; 8 | 9 | export default deleteTodo; 10 | -------------------------------------------------------------------------------- /demo/src/pages/updates/Child.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { collect, PropTypes } from 'react-recollect'; 3 | import StorePropType from '../../propTypes/StorePropType'; 4 | 5 | class Child extends React.Component { 6 | renderCount = 1; 7 | 8 | render() { 9 | const { text } = this.props.store.batchUpdatePage; 10 | 11 | return ( 12 |
    13 |

    {`Child renders: ${this.renderCount++}`}

    14 |

    {`Child value: ${text}`}

    15 |

    {`Child value fromParent: ${this.props.fromParent}`}

    16 |
    17 | ); 18 | } 19 | } 20 | 21 | Child.propTypes = { 22 | fromParent: PropTypes.string.isRequired, 23 | store: StorePropType.isRequired, 24 | }; 25 | 26 | export default collect(Child); 27 | -------------------------------------------------------------------------------- /demo/src/pages/updates/GridItem.js: -------------------------------------------------------------------------------- 1 | import React, { useRef } from 'react'; 2 | import { collect, PropTypes } from 'react-recollect'; 3 | import StorePropType from '../../propTypes/StorePropType'; 4 | import styles from './GridItem.module.css'; 5 | import throttledUpdate from './throttledUpdate'; 6 | 7 | const GridItem = ({ store, id, children }) => { 8 | const countRef = useRef(0); 9 | countRef.current++; 10 | 11 | const elRef = useRef(); 12 | const timeoutRef = useRef(); 13 | 14 | const pos = store.batchUpdatePage.grid[id]; 15 | 16 | const handleTouch = (e) => { 17 | e.stopPropagation(); 18 | 19 | const nextValue = { 20 | x: e.nativeEvent.screenX, 21 | y: e.nativeEvent.screenY, 22 | }; 23 | 24 | throttledUpdate(() => { 25 | store.batchUpdatePage.grid[id] = nextValue; 26 | }); 27 | }; 28 | 29 | if (elRef.current) { 30 | // Flash the border on re-render 31 | clearTimeout(timeoutRef.current); 32 | elRef.current.style.outline = '1px solid #E91E63'; 33 | 34 | timeoutRef.current = setTimeout(() => { 35 | elRef.current.style.outline = ''; 36 | }, 150); 37 | } 38 | 39 | return ( 40 |
    46 |
    47 |

    RC: {countRef.current}

    48 |

    x: {pos.x}

    49 |

    y: {pos.y}

    50 |

    ID: {id}

    51 |
    52 | 53 | {!!children &&
    {children}
    } 54 |
    55 | ); 56 | }; 57 | 58 | GridItem.propTypes = { 59 | children: PropTypes.node, 60 | id: PropTypes.string.isRequired, 61 | store: StorePropType.isRequired, 62 | }; 63 | 64 | export default collect(GridItem); 65 | -------------------------------------------------------------------------------- /demo/src/pages/updates/GridItem.module.css: -------------------------------------------------------------------------------- 1 | .gridItem { 2 | position: relative; 3 | flex: auto; 4 | min-width: 0; 5 | padding: 4px; 6 | background: hsla(200, 18%, 46%, 0.08); 7 | outline: 1px solid white; 8 | outline-offset: -1px; 9 | box-shadow: 1px 1px 4px 1px rgba(0, 0, 0, 0.05); 10 | transition: 300ms; 11 | } 12 | 13 | .textWrapper { 14 | min-height: 100px; 15 | min-width: 100px; 16 | } 17 | 18 | .text { 19 | margin: 0 0 4px; 20 | font-family: monospace; 21 | color: #444; 22 | } 23 | 24 | .childrenWrapper { 25 | display: flex; 26 | } 27 | -------------------------------------------------------------------------------- /demo/src/pages/updates/Parent.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { collect } from 'react-recollect'; 3 | import StorePropType from '../../propTypes/StorePropType'; 4 | import Child from './Child'; 5 | 6 | class Parent extends React.Component { 7 | renderCount = 1; 8 | 9 | render() { 10 | const { text } = this.props.store.batchUpdatePage; 11 | 12 | return ( 13 |
    14 |

    {`Parent renders: ${this.renderCount++}`}

    15 |

    {`Parent value: ${text}`}

    16 | 17 |
    18 | ); 19 | } 20 | } 21 | 22 | Parent.propTypes = { 23 | store: StorePropType.isRequired, 24 | }; 25 | 26 | export default collect(Parent); 27 | -------------------------------------------------------------------------------- /demo/src/pages/updates/Updates.js: -------------------------------------------------------------------------------- 1 | import Box from '@material-ui/core/Box'; 2 | import Container from '@material-ui/core/Container'; 3 | import Paper from '@material-ui/core/Paper'; 4 | import Typography from '@material-ui/core/Typography'; 5 | import React from 'react'; 6 | import { collect } from 'react-recollect'; 7 | import StorePropType from '../../propTypes/StorePropType'; 8 | import GridItem from './GridItem'; 9 | import Parent from './Parent'; 10 | 11 | const getGrid = () => ( 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | ); 40 | 41 | const Updates = ({ store }) => ( 42 | 43 | 44 | 45 | Targeted updates 46 |

    47 | This is for testing/demonstrating components re-rendering only when 48 | their data changes. Best viewed not on a phone. 49 |

    50 | 51 |

    52 | Each box updates the store on mouse move, and re-renders when it’s 53 | data changes. When a box renders, it will flash its border. Note that 54 | when a component re-renders, it doesn’t mean that it’s children 55 | re-render too. 56 |

    57 | 58 |

    RC = render count

    59 | 60 | 61 | {getGrid()} 62 | 63 | {getGrid()} 64 | 65 |
    66 |
    67 | 68 | 69 | 70 | Batched updates 71 | 72 |

    73 | This demonstrates updates to multiple components being batched into a 74 | single render cycle, even when the store change takes place outside a 75 | React event handler. 76 |

    77 | 78 |
    79 | 80 |

    81 | The button below changes the store within an onClick handler. In this 82 | case, React will batch updates and trigger a single render by default 83 |

    84 | 85 | 92 | 93 |
    94 | 95 |

    96 | The button below changes the store outside of a onClick handler (in a 97 | setTimeout callback). In this case, React can’t batch updates, but the 98 | batching is handled internally by Recollect. 99 |

    100 | 101 | 110 | 111 |
    112 | 113 | 114 |
    115 |
    116 |
    117 | ); 118 | 119 | Updates.propTypes = { 120 | store: StorePropType.isRequired, 121 | }; 122 | 123 | export default collect(Updates); 124 | -------------------------------------------------------------------------------- /demo/src/pages/updates/throttledUpdate.js: -------------------------------------------------------------------------------- 1 | let nextAction = null; 2 | 3 | const throttledUpdate = (action) => { 4 | if (!nextAction) { 5 | nextAction = action; 6 | 7 | requestAnimationFrame(() => { 8 | nextAction(); 9 | nextAction = null; 10 | }); 11 | } else { 12 | nextAction = action; 13 | } 14 | }; 15 | 16 | export default throttledUpdate; 17 | -------------------------------------------------------------------------------- /demo/src/propTypes/StorePropType.js: -------------------------------------------------------------------------------- 1 | import { PropTypes } from 'react-recollect'; 2 | import ProductPagePropType from '../pages/products/propTypes/ProductPagePropType'; 3 | import { PAGES } from '../shared/constants'; 4 | import TodoMvcPropType from '../pages/todomvc/propTypes/TodoMvcPropType'; 5 | 6 | const StorePropType = PropTypes.shape({ 7 | currentPage: PropTypes.oneOf(Object.values(PAGES)).isRequired, 8 | loading: PropTypes.bool.isRequired, 9 | productPage: ProductPagePropType.isRequired, 10 | todoMvcPage: TodoMvcPropType, 11 | batchUpdatePage: PropTypes.shape({ 12 | text: PropTypes.string.isRequired, 13 | grid: PropTypes.shape({}), 14 | }), 15 | }); 16 | 17 | export default StorePropType; 18 | -------------------------------------------------------------------------------- /demo/src/shared/Theme.js: -------------------------------------------------------------------------------- 1 | import { blueGrey } from '@material-ui/core/colors'; 2 | import { createMuiTheme, ThemeProvider } from '@material-ui/core/styles'; 3 | import React from 'react'; 4 | import { PropTypes } from 'react-recollect'; 5 | 6 | const theme = createMuiTheme({ 7 | palette: { 8 | primary: blueGrey, 9 | }, 10 | }); 11 | 12 | const Theme = ({ children }) => ( 13 | {children} 14 | ); 15 | 16 | Theme.propTypes = { 17 | children: PropTypes.node.isRequired, 18 | }; 19 | 20 | export default Theme; 21 | -------------------------------------------------------------------------------- /demo/src/shared/constants.js: -------------------------------------------------------------------------------- 1 | export const VISIBILITY_FILTERS = { 2 | SHOW_ALL: 'All', 3 | SHOW_COMPLETED: 'Completed', 4 | SHOW_ACTIVE: 'Active', 5 | }; 6 | 7 | export const PAGES = { 8 | UPDATES: 'Updates', 9 | PRODUCTS: 'Products', 10 | BIG_TREE: 'Big tree', 11 | TODO_MVC: 'Todo MVC', 12 | }; 13 | 14 | export const PRODUCT_FILTER = { 15 | ALL: 'ALL', 16 | FAVOURITES: 'FAVOURITES', 17 | }; 18 | 19 | export const LOAD_STATUSES = { 20 | NOT_STARTED: 'NOT_STARTED', 21 | LOADING: 'LOADING', 22 | LOADED: 'LOADED', 23 | }; 24 | 25 | export const TYPES = { 26 | OBJ: 'Object', 27 | ARR: 'Array', 28 | MAP: 'Map', 29 | SET: 'Set', 30 | }; 31 | -------------------------------------------------------------------------------- /index.cjs.js: -------------------------------------------------------------------------------- 1 | if (process.env.NODE_ENV === 'production') { 2 | // eslint-disable-next-line global-require 3 | module.exports = require('./dist/cjs/index.production.js'); 4 | } else { 5 | // eslint-disable-next-line global-require 6 | module.exports = require('./dist/cjs/index.development.js'); 7 | } 8 | -------------------------------------------------------------------------------- /index.esm.js: -------------------------------------------------------------------------------- 1 | if (process.env.NODE_ENV === 'production') { 2 | // eslint-disable-next-line global-require 3 | module.exports = require('./dist/esm/production/index.js'); 4 | } else { 5 | // eslint-disable-next-line global-require 6 | module.exports = require('./dist/esm/development/index.js'); 7 | } 8 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | setupFilesAfterEnv: [ 4 | '@testing-library/jest-dom/extend-expect', 5 | './jestSetup.js', 6 | ], 7 | clearMocks: true, 8 | modulePaths: ['/'], 9 | watchPathIgnorePatterns: [ 10 | 'node_modules/.cache', // don't listen to rollup-typescript cache 11 | 'src', // don't listen to source, it's /dist changes we care about 12 | 'dist/esm', // just cjs 13 | ], 14 | }; 15 | -------------------------------------------------------------------------------- /jestSetup.js: -------------------------------------------------------------------------------- 1 | beforeEach(() => { 2 | // Not all tests load the whole library, so this can be undefined 3 | if (window.__RR__) window.__RR__.clearHistory(); 4 | }); 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-recollect", 3 | "version": "5.2.3", 4 | "description": "Simple state management for react", 5 | "keywords": [ 6 | "flux", 7 | "react", 8 | "redux", 9 | "state", 10 | "state management" 11 | ], 12 | "bugs": "https://github.com/davidgilbertson/react-recollect/issues", 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/davidgilbertson/react-recollect" 16 | }, 17 | "license": "MIT", 18 | "author": "David Gilbertson", 19 | "main": "index.cjs.js", 20 | "module": "index.esm.js", 21 | "unpkg": "dist/umd/index.production.js", 22 | "types": "dist/types/index.d.ts", 23 | "files": [ 24 | "index.*", 25 | "dist" 26 | ], 27 | "scripts": { 28 | "build": "rollup -c", 29 | "build:watch": "rollup -c --watch", 30 | "check:all": "npm run build && npm run lint && npm run test:ci", 31 | "lint": "eslint src tests --fix --cache --ext js,ts,tsx --max-warnings=0", 32 | "nodeTest": "node ./tests/integration/nodeJs.js", 33 | "prepublishOnly": "npm run check:all", 34 | "readme:toc": "doctoc README.md", 35 | "test": "npm run nodeTest && jest ./test --watch", 36 | "test:ci": "npm run nodeTest && jest ./test --ci", 37 | "preversion": "npm run check:all" 38 | }, 39 | "dependencies": { 40 | "@types/prop-types": "^15.7.3", 41 | "hoist-non-react-statics": "^3.3.0", 42 | "prop-types": "^15.7.2" 43 | }, 44 | "devDependencies": { 45 | "@rollup/plugin-commonjs": "^11.0.2", 46 | "@rollup/plugin-node-resolve": "^7.1.1", 47 | "@rollup/plugin-replace": "^2.3.1", 48 | "@testing-library/jest-dom": "^5.1.1", 49 | "@testing-library/react": "^10.0.1", 50 | "@types/hoist-non-react-statics": "^3.3.1", 51 | "@types/jest": "^25.1.4", 52 | "@types/node": "^13.9.3", 53 | "@types/react": "^16.9.23", 54 | "@types/react-dom": "^16.9.5", 55 | "@types/testing-library__dom": "^7.0.0", 56 | "@types/testing-library__jest-dom": "^5.0.2", 57 | "@types/testing-library__react": "^9.1.3", 58 | "@typescript-eslint/eslint-plugin": "^2.24.0", 59 | "@typescript-eslint/parser": "^2.24.0", 60 | "doctoc": "^1.4.0", 61 | "eslint": "^6.8.0", 62 | "eslint-config-airbnb": "^18.1.0", 63 | "eslint-config-airbnb-typescript": "^7.2.0", 64 | "eslint-config-prettier": "^6.10.1", 65 | "eslint-plugin-import": "^2.20.1", 66 | "eslint-plugin-jsx-a11y": "^6.2.3", 67 | "eslint-plugin-prettier": "^3.1.2", 68 | "eslint-plugin-react": "^7.19.0", 69 | "eslint-plugin-react-hooks": "^2.4.0", 70 | "jest": "^25.1.0", 71 | "lodash": "^4.17.21", 72 | "prettier": "^2.0.1", 73 | "react": "^16.13.0", 74 | "react-dom": "^16.13.0", 75 | "rollup": "^2.2.0", 76 | "rollup-plugin-bundle-size": "^1.0.3", 77 | "rollup-plugin-terser": "^5.3.0", 78 | "rollup-plugin-typescript2": "^0.27.0", 79 | "size-plugin": "^2.0.1", 80 | "source-map-loader": "^0.2.4", 81 | "ts-jest": "^25.2.1", 82 | "ts-loader": "^6.2.2", 83 | "tslib": "^1.11.1", 84 | "typescript": "^3.8.3" 85 | }, 86 | "peerDependencies": { 87 | "react-dom": ">=15.3", 88 | "react": ">=15.3" 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import lodashMerge from 'lodash/merge'; 2 | import commonjs from '@rollup/plugin-commonjs'; 3 | import typescript from 'rollup-plugin-typescript2'; 4 | import { terser } from 'rollup-plugin-terser'; 5 | import nodeResolve from '@rollup/plugin-node-resolve'; 6 | import replace from '@rollup/plugin-replace'; 7 | import bundleSize from 'rollup-plugin-bundle-size'; 8 | import pkg from './package.json'; 9 | 10 | const merge = (...args) => lodashMerge({}, ...args); 11 | 12 | const EXTERNALS = { 13 | ALL: Object.keys(merge(pkg.peerDependencies, pkg.dependencies)), 14 | PEERS: Object.keys(pkg.peerDependencies), 15 | }; 16 | 17 | const GLOBALS = { 18 | react: 'React', 19 | 'react-dom': 'ReactDOM', 20 | }; 21 | 22 | const FORMATS = { 23 | CJS: 'cjs', 24 | ESM: 'esm', 25 | UMD: 'umd', 26 | }; 27 | 28 | const ENVIRONMENTS = { 29 | DEV: 'development', 30 | PRD: 'production', 31 | }; 32 | 33 | // These are settings we don't want in tsconfig.json 34 | // because they interfere with tests/linting 35 | const tsConfigBase = { 36 | check: false, 37 | tsconfigOverride: { 38 | compilerOptions: { 39 | module: 'ES2015', 40 | }, 41 | include: ['src'], 42 | }, 43 | }; 44 | 45 | // These options are slow and only need to be executed for one of the configs 46 | const tsConfigExtended = { 47 | check: true, 48 | useTsconfigDeclarationDir: true, 49 | tsconfigOverride: { 50 | compilerOptions: { 51 | declaration: true, 52 | declarationDir: 'dist/types', 53 | }, 54 | }, 55 | }; 56 | 57 | export default (flags) => { 58 | const configs = []; 59 | 60 | // We loop over the 3 formats and 2 environments 61 | Object.values(FORMATS).forEach((format) => { 62 | Object.values(ENVIRONMENTS).forEach((env) => { 63 | // Only one of the configs needs to run checks and output TS declarations 64 | // For performance, we'll pick one that doesn't run in watch mode 65 | const tsConfig = 66 | format === FORMATS.UMD && env === ENVIRONMENTS.PRD 67 | ? merge(tsConfigBase, tsConfigExtended) 68 | : tsConfigBase; 69 | 70 | // Shared options for all configs 71 | const config = { 72 | cache: true, 73 | input: 'src/index.ts', 74 | output: { 75 | format, 76 | }, 77 | plugins: [ 78 | commonjs(), 79 | nodeResolve(), 80 | typescript(tsConfig), // must be after nodeResolve 81 | replace({ 'process.env.NODE_ENV': `'${env}'` }), // must be after typescript 82 | // terser will be added after this for minified bundles 83 | ], 84 | }; 85 | 86 | // We don't concatenate for ESM 87 | if (format === FORMATS.ESM) { 88 | config.preserveModules = true; 89 | config.output.dir = `dist/${format}/${env}`; 90 | } else { 91 | config.output.file = `dist/${format}/index.${env}.js`; 92 | } 93 | 94 | // UMD files only externalise peers 95 | if (format === FORMATS.UMD) { 96 | config.external = EXTERNALS.PEERS; 97 | config.output.name = 'ReactRecollect'; 98 | config.output.globals = GLOBALS; 99 | } else { 100 | // Other formats externalise everything 101 | config.external = EXTERNALS.ALL; 102 | } 103 | 104 | // We minify for UMD production 105 | if (format === FORMATS.UMD && env === ENVIRONMENTS.PRD) { 106 | config.plugins.push(terser()); 107 | config.plugins.push(bundleSize()); 108 | } 109 | 110 | // We only build some configs in watch mode 111 | // Further down we filter based on config.watch 112 | if ( 113 | env === ENVIRONMENTS.DEV && 114 | (format === FORMATS.CJS || format === FORMATS.ESM) 115 | ) { 116 | config.watch = { 117 | include: 'src/**/*', 118 | }; 119 | } 120 | 121 | configs.push(config); 122 | }); 123 | }); 124 | 125 | return flags.watch ? configs.filter((config) => config.watch) : configs; 126 | }; 127 | -------------------------------------------------------------------------------- /src/collect.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import hoistNonReactStatics from 'hoist-non-react-statics'; 3 | import * as updateManager from './updateManager'; 4 | import * as proxyManager from './proxyManager'; 5 | import * as paths from './shared/paths'; 6 | import state from './shared/state'; 7 | import { debug } from './shared/debug'; 8 | import { CollectorComponent, Store, WithStoreProp } from './shared/types'; 9 | import { whileMuted } from './shared/utils'; 10 | 11 | // As we render down into a tree of collected components, we will start/stop 12 | // recording 13 | const componentStack: CollectorComponent[] = []; 14 | 15 | const startRecordingGetsForComponent = (component: CollectorComponent) => { 16 | if (!state.isInBrowser) return; 17 | 18 | debug(() => { 19 | console.groupCollapsed(`RENDER: <${component._name}>`); 20 | }); 21 | 22 | state.currentComponent = component; 23 | componentStack.push(state.currentComponent); 24 | }; 25 | 26 | const stopRecordingGetsForComponent = () => { 27 | if (!state.isInBrowser) return; 28 | 29 | debug(() => { 30 | console.groupEnd(); 31 | }); 32 | 33 | componentStack.pop(); 34 | state.currentComponent = componentStack[componentStack.length - 1] || null; 35 | }; 36 | 37 | type RemoveStore = Pick>; 38 | type ComponentPropsWithoutStore = RemoveStore< 39 | React.ComponentProps 40 | >; 41 | 42 | /** 43 | * This shallow clones the store to pass as state to the collected 44 | * component. 45 | */ 46 | const getStoreClone = () => 47 | whileMuted(() => { 48 | // We'll shallow clone the store so React knows it's new 49 | const shallowClone = { ...state.store }; 50 | 51 | // ... but redirect all reads to the real store 52 | state.nextVersionMap.set(shallowClone, state.store); 53 | 54 | return proxyManager.createShallow(shallowClone); 55 | }); 56 | 57 | const collect = >( 58 | ComponentToWrap: C 59 | ): React.ComponentType> & 60 | CollectorComponent & 61 | hoistNonReactStatics.NonReactStatics => { 62 | const componentName = 63 | ComponentToWrap.displayName || ComponentToWrap.name || 'NamelessComponent'; 64 | 65 | // The component that's passed in will require a `store` prop. The returned 66 | // component will not require a `store` prop, so we remove it 67 | type Props = ComponentPropsWithoutStore; 68 | 69 | type ComponentState = { 70 | store: Store; 71 | }; 72 | 73 | class WrappedComponent extends React.PureComponent 74 | implements CollectorComponent { 75 | state = { 76 | // This might be called by React when a parent component has updated with a new store, 77 | // we want this component (if it's a child) to have that next store as well. 78 | store: getStoreClone(), 79 | }; 80 | 81 | // TODO (davidg) 2020-02-28: use private #isMounted, waiting on 82 | // https://github.com/prettier/prettier/issues/7263 83 | private _isMounted = false; 84 | 85 | private _isMounting = true; 86 | 87 | // will trigger multiple renders, 88 | // we must disregard these 89 | private _isRendering = false; 90 | 91 | _name = componentName; 92 | 93 | static displayName = `Collected(${componentName})`; 94 | 95 | componentDidMount() { 96 | this._isMounted = true; 97 | this._isMounting = false; 98 | 99 | // A user shouldn't pass data from the store into a collected component. 100 | // See the issue linked in the error for details. 101 | if (process.env.NODE_ENV !== 'production') { 102 | if (this.props) { 103 | const recollectStoreProps: string[] = []; 104 | 105 | // Note this is only a shallow check. 106 | Object.entries(this.props).forEach(([propName, propValue]) => { 107 | // If this prop has a 'path', we know it's from the Recollect store 108 | // This is not good! 109 | if (paths.has(propValue)) recollectStoreProps.push(propName); 110 | }); 111 | 112 | // We'll just report the first match to keep the message simple 113 | if (recollectStoreProps.length) { 114 | console.error( 115 | `You are passing part of the Recollect store from one collected component to another, which can cause unpredictable behaviour.\n Either remove the collect() wrapper from <${this._name}/>, or remove the "${recollectStoreProps[0]}" prop.\n More info: https://git.io/JvMOj` 116 | ); 117 | } 118 | } 119 | } 120 | 121 | // Stop recording. For first render() 122 | stopRecordingGetsForComponent(); 123 | this._isRendering = false; 124 | } 125 | 126 | componentDidUpdate() { 127 | // Stop recording. For not-first render() 128 | stopRecordingGetsForComponent(); 129 | this._isRendering = false; 130 | } 131 | 132 | componentWillUnmount() { 133 | updateManager.removeListenersForComponent(this); 134 | this._isMounted = false; 135 | } 136 | 137 | update() { 138 | // 1. If the component has already unmounted, don't try and set the state 139 | // 2. The component might not have mounted YET, but is in the middle of its first 140 | // render cycle. 141 | // For example, if a user sets store.loading to true in App.componentDidMount 142 | if (this._isMounted || this._isMounting) { 143 | this.setState({ store: getStoreClone() }); 144 | } 145 | } 146 | 147 | render() { 148 | if (!this._isRendering) { 149 | startRecordingGetsForComponent(this); 150 | this._isRendering = true; 151 | } 152 | 153 | const props = { 154 | ...this.props, 155 | store: this.state.store, 156 | } as React.ComponentProps; 157 | 158 | return ; 159 | } 160 | } 161 | 162 | // @ts-ignore - I can't work this out 163 | return hoistNonReactStatics(WrappedComponent, ComponentToWrap); 164 | }; 165 | 166 | export default collect; 167 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | debugOff, 3 | debugOn, 4 | getComponentsByListener, 5 | getListenersByComponent, 6 | } from './shared/debug'; 7 | import state from './shared/state'; 8 | import { 9 | back, 10 | clearHistory, 11 | forward, 12 | getHistory, 13 | goTo, 14 | setHistoryLimit, 15 | } from './shared/timeTravel'; 16 | 17 | export { default as collect } from './collect'; 18 | export { afterChange } from './shared/pubSub'; 19 | export { initStore, batch } from './store'; 20 | export { useProps } from './shared/utils'; 21 | export { default as PropTypes } from './shared/propTypes'; 22 | export const { store } = state; 23 | 24 | // `internals` is not part of the Recollect API. It is used by tests. 25 | export const internals = state; 26 | 27 | export { AfterChangeEvent } from './shared/types'; 28 | export { WithStoreProp } from './shared/types'; 29 | export { Store } from './shared/types'; 30 | 31 | if (typeof window !== 'undefined') { 32 | if ('Proxy' in window) { 33 | window.__RR__ = { 34 | debugOn, 35 | debugOff, 36 | internals: state, 37 | }; 38 | 39 | if (process.env.NODE_ENV !== 'production') { 40 | // These two helpers will be included in the dev build only. A) for size, but 41 | // also B) in prod, component names tend to be obscured so they would be 42 | // of little use. 43 | window.__RR__.getListenersByComponent = getListenersByComponent; 44 | window.__RR__.getComponentsByListener = getComponentsByListener; 45 | 46 | // Time travel helpers have a performance/memory impact, so are only 47 | // included in the dev build 48 | window.__RR__.back = back; 49 | window.__RR__.forward = forward; 50 | window.__RR__.goTo = goTo; 51 | window.__RR__.getHistory = getHistory; 52 | window.__RR__.clearHistory = clearHistory; 53 | window.__RR__.setHistoryLimit = setHistoryLimit; 54 | } 55 | } else { 56 | console.warn( 57 | "This browser doesn't support the Proxy object, which react-recollect needs. See https://caniuse.com/#search=proxy to find out which browsers do support it" 58 | ); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/shared/batchedUpdates.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-unresolved */ 2 | import ReactDOM from 'react-dom'; 3 | 4 | // unstable_batchedUpdates could be removed in a future major version 5 | // So we'll provide a fallback 6 | // https://github.com/facebook/react/issues/18602 7 | export default ReactDOM.unstable_batchedUpdates || 8 | ((cb: () => void) => { 9 | cb(); 10 | }); 11 | -------------------------------------------------------------------------------- /src/shared/constants.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * To convert the path array to a string for the listener keys 3 | * Use a crazy separator. If the separator was a '.', and the user had a prop with a dot in it, 4 | * then it could cause false matches in the updated logic. 5 | */ 6 | export const PROP_PATH_SEP = '~~~'; 7 | 8 | export const PATH = Symbol('PATH'); 9 | 10 | export const ORIGINAL = Symbol('ORIGINAL'); 11 | 12 | export const LS_KEYS = { 13 | RR_HISTORY_LIMIT: 'RR_HISTORY_LIMIT', 14 | RR_DEBUG: 'RR_DEBUG', 15 | }; 16 | 17 | /** Some of the map and set methods */ 18 | export const enum MapOrSetMembers { 19 | Get = 'get', 20 | Add = 'add', 21 | Clear = 'clear', 22 | Delete = 'delete', 23 | Set = 'set', 24 | } 25 | 26 | /** Some of the array methods, and length */ 27 | export const enum ArrayMembers { 28 | // Mutating methods 29 | CopyWithin = 'copyWithin', 30 | Fill = 'fill', 31 | Pop = 'pop', 32 | Push = 'push', 33 | Reverse = 'reverse', 34 | Shift = 'shift', 35 | Sort = 'sort', 36 | Splice = 'splice', 37 | Unshift = 'unshift', 38 | // Properties 39 | Length = 'length', 40 | } 41 | -------------------------------------------------------------------------------- /src/shared/debug.ts: -------------------------------------------------------------------------------- 1 | import { CollectorComponent, Target } from './types'; 2 | import * as paths from './paths'; 3 | import * as ls from './ls'; 4 | import * as utils from './utils'; 5 | import state from './state'; 6 | import { LS_KEYS } from './constants'; 7 | 8 | const DEBUG_ON = 'on'; 9 | const DEBUG_OFF = 'off'; 10 | 11 | let DEBUG = ls.get(LS_KEYS.RR_DEBUG) || DEBUG_OFF; 12 | 13 | if (DEBUG === DEBUG_ON) { 14 | console.info( 15 | 'Recollect debugging is enabled. Type __RR__.debugOff() to turn it off.' 16 | ); 17 | } 18 | 19 | export const debugOn = () => { 20 | DEBUG = DEBUG_ON; 21 | ls.set(LS_KEYS.RR_DEBUG, DEBUG_ON); 22 | }; 23 | 24 | export const debugOff = () => { 25 | DEBUG = DEBUG_OFF; 26 | ls.set(LS_KEYS.RR_DEBUG, DEBUG_OFF); 27 | }; 28 | 29 | export const debug = (cb: () => void) => { 30 | if (DEBUG === DEBUG_ON) cb(); 31 | }; 32 | 33 | export const logGet = (target: Target, prop?: any, value?: any) => { 34 | debug(() => { 35 | console.groupCollapsed(`GET: ${paths.extendToUserString(target, prop)}`); 36 | console.info(`Component: <${state.currentComponent!._name}>`); 37 | if (typeof value !== 'undefined') { 38 | console.info('Value:', value); 39 | } 40 | console.groupEnd(); 41 | }); 42 | }; 43 | 44 | export const logSet = (target: Target, prop: any, value?: any) => { 45 | debug(() => { 46 | console.groupCollapsed(`SET: ${paths.extendToUserString(target, prop)}`); 47 | console.info('From:', utils.getValue(target, prop)); 48 | console.info('To: ', value); 49 | console.groupEnd(); 50 | }); 51 | }; 52 | 53 | export const logDelete = (target: Target, prop: any) => { 54 | debug(() => { 55 | console.groupCollapsed(`DELETE: ${paths.extendToUserString(target, prop)}`); 56 | console.info('Property: ', paths.extendToUserString(target, prop)); 57 | console.groupEnd(); 58 | }); 59 | }; 60 | 61 | export const logUpdate = ( 62 | component: CollectorComponent, 63 | propsUpdated: string[] 64 | ) => { 65 | debug(() => { 66 | console.groupCollapsed(`UPDATE: <${component._name}>`); 67 | console.info('Changed properties:', propsUpdated); 68 | console.groupEnd(); 69 | }); 70 | }; 71 | 72 | type NameMaker = (obj: any) => string; 73 | type Matcher = string | RegExp; 74 | 75 | const getComponentsAndListeners = ( 76 | componentFirst: boolean, 77 | matcher?: Matcher, 78 | makeName?: NameMaker 79 | ) => { 80 | const result: { [p: string]: string[] } = {}; 81 | 82 | Array.from(state.listeners).forEach(([path, componentSet]) => { 83 | componentSet.forEach((component) => { 84 | let componentName = component._name; 85 | if (makeName) { 86 | componentName += makeName(component.props) ?? ''; 87 | } 88 | const userPath = paths.internalToUser(path); 89 | 90 | const prop = componentFirst ? componentName : userPath; 91 | const value = componentFirst ? userPath : componentName; 92 | 93 | if (matcher && !prop.match(matcher)) return; 94 | 95 | if (!result[prop]) result[prop] = []; 96 | if (!result[prop].includes(value)) result[prop].push(value); 97 | }); 98 | }); 99 | 100 | return result; 101 | }; 102 | 103 | /** 104 | * Return an object where the keys are component names and the values are 105 | * arrays of the store properties the component is subscribed to 106 | */ 107 | export const getListenersByComponent = ( 108 | matcher?: Matcher, 109 | makeName?: NameMaker 110 | ) => getComponentsAndListeners(true, matcher, makeName); 111 | 112 | /** 113 | * Return an object where the keys are store properties and the values are 114 | * the names of the components that listen to the property 115 | */ 116 | export const getComponentsByListener = ( 117 | matcher?: Matcher, 118 | makeName?: NameMaker 119 | ) => getComponentsAndListeners(false, matcher, makeName); 120 | -------------------------------------------------------------------------------- /src/shared/ls.ts: -------------------------------------------------------------------------------- 1 | const hasLocalStorage = typeof window !== 'undefined' && !!window.localStorage; 2 | 3 | export const set = (key: string, data: any) => { 4 | if (!hasLocalStorage) return undefined; 5 | 6 | try { 7 | const string = typeof data === 'string' ? data : JSON.stringify(data); 8 | 9 | return localStorage.setItem(key, string); 10 | } catch (err) { 11 | return undefined; 12 | } 13 | }; 14 | 15 | export const get = (key: string) => { 16 | if (!hasLocalStorage) return undefined; 17 | 18 | const data = localStorage.getItem(key); 19 | if (!data) return data; 20 | 21 | try { 22 | return JSON.parse(data); 23 | } catch (err) { 24 | // So we return whatever we've got on a failure 25 | // E.g. the data could be a plain string, which errors on JSON.parse. 26 | return data; 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /src/shared/paths.ts: -------------------------------------------------------------------------------- 1 | import { PATH, PROP_PATH_SEP } from './constants'; 2 | import * as utils from './utils'; 3 | import { PropPath, Target } from './types'; 4 | 5 | // Joins an array that potentially contains symbols, which need an explicit 6 | // 'toString()' 7 | const join = (arr: any[], joiner?: string) => 8 | arr.map((item: any) => item.toString()).join(joiner); 9 | 10 | /** 11 | * Convert a target and a prop into a user-friendly string like store.tasks.1.done 12 | */ 13 | export const makeUserString = (propPath: PropPath) => join(propPath, '.'); 14 | 15 | export const makeInternalString = (propPath: PropPath) => 16 | join(propPath, PROP_PATH_SEP); 17 | 18 | /** 19 | * Convert an internal string like `one~~~two~~~three` into a user-facing string 20 | * like `one.two.three` 21 | */ 22 | export const internalToUser = (internalPath: string) => 23 | makeUserString(internalPath.split(PROP_PATH_SEP)); 24 | 25 | /** 26 | * Takes the path stored in an object, and a new prop, and returns the two 27 | * combined 28 | */ 29 | export const extend = (target: Target, prop?: any): PropPath => { 30 | const basePath = target[PATH] || []; 31 | 32 | if (typeof prop === 'undefined') return basePath; 33 | 34 | return basePath.concat(prop); 35 | }; 36 | 37 | /** 38 | * Convert a target and a prop into a user-friendly string like store.tasks.1.done 39 | */ 40 | export const extendToUserString = (target: Target, prop?: any): string => 41 | makeUserString(extend(target, prop)); 42 | 43 | export const addProp = (target: Target, propPath: PropPath) => { 44 | if (!target) return; 45 | 46 | Object.defineProperty(target, PATH, { 47 | value: propPath, 48 | writable: true, // paths can be updated. E.g. store.tasks.2 could become store.tasks.1 49 | }); 50 | }; 51 | 52 | export const get = (target: Target) => target[PATH] || []; 53 | 54 | export const has = (target: any) => utils.isObject(target) && PATH in target; 55 | 56 | export const set = (mutableTarget: Target, propPath: PropPath) => { 57 | mutableTarget[PATH] = propPath; 58 | }; 59 | -------------------------------------------------------------------------------- /src/shared/propTypes.ts: -------------------------------------------------------------------------------- 1 | import * as OriginalPropTypes from 'prop-types'; 2 | import { whileMuted } from './utils'; 3 | 4 | let EnvPropTypes; 5 | 6 | // In the dev build of Recollect, we wrap PropTypes in a proxy so we can 7 | // mute the store while the prop types library reads the props. 8 | if (process.env.NODE_ENV !== 'production') { 9 | // The PropTypes object is made up of functions that call functions, so 10 | // we recursively wrap responses in the same handler 11 | const wrapMeIfYouCan = (item: any, handler: ProxyHandler) => { 12 | if ( 13 | typeof item === 'function' || 14 | (typeof item === 'object' && item !== null) 15 | ) { 16 | return new Proxy(item, handler); 17 | } 18 | return item; 19 | }; 20 | 21 | const handler: ProxyHandler = { 22 | get(...args) { 23 | return wrapMeIfYouCan(Reflect.get(...args), this); 24 | }, 25 | apply(...args) { 26 | // Here we mute the function calls 27 | const result = whileMuted(() => Reflect.apply(...args)); 28 | return wrapMeIfYouCan(result, this); 29 | }, 30 | }; 31 | 32 | EnvPropTypes = new Proxy(OriginalPropTypes, handler); 33 | } else { 34 | // For prod builds, just use the normal PropTypes which is a no-op 35 | EnvPropTypes = OriginalPropTypes; 36 | } 37 | 38 | // We do this so we're exporting a const (EnvPropTypes is a let) 39 | const PropTypes: typeof OriginalPropTypes = EnvPropTypes; 40 | 41 | export default PropTypes; 42 | -------------------------------------------------------------------------------- /src/shared/pubSub.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file is unfortunate. I would prefer for the proxy handler to call 3 | * `updateStore` directly, but there is circular logic: 4 | * object change » handler » update store » clone » create proxy » handler 5 | * The logic is sound (?), but the import loop needs to be broken somewhere, 6 | * hence this file. 7 | */ 8 | import { AfterChangeEvent, UpdateInStore, UpdateInStoreProps } from './types'; 9 | import state from './state'; 10 | 11 | const enum ActionTypes { 12 | UpdateNextStore = 'UpdateNextStore', 13 | } 14 | 15 | type Subscribers = { 16 | UpdateNextStore?: UpdateInStore; 17 | }; 18 | 19 | const subscribers: Subscribers = {}; 20 | 21 | export const onUpdateInNextStore = (func: UpdateInStore) => { 22 | subscribers[ActionTypes.UpdateNextStore] = func; 23 | }; 24 | 25 | export const dispatchUpdateInNextStore = (data: UpdateInStoreProps) => { 26 | return subscribers[ActionTypes.UpdateNextStore]!(data); 27 | }; 28 | 29 | /** 30 | * Add a callback to be called every time the store changes 31 | */ 32 | export const afterChange = (cb: (e: AfterChangeEvent) => void) => { 33 | state.manualListeners.push(cb); 34 | }; 35 | -------------------------------------------------------------------------------- /src/shared/state.ts: -------------------------------------------------------------------------------- 1 | import { State } from './types'; 2 | 3 | /** 4 | * Any state shared between modules goes here 5 | * For internal use, not for consumers 6 | */ 7 | const state: State = { 8 | currentComponent: null, 9 | isBatchUpdating: false, 10 | isInBrowser: typeof window !== 'undefined', 11 | listeners: new Map(), 12 | manualListeners: [], 13 | nextVersionMap: new WeakMap(), 14 | proxyIsMuted: false, 15 | redirectToNext: true, 16 | store: {}, 17 | }; 18 | 19 | export default state; 20 | -------------------------------------------------------------------------------- /src/shared/timeTravel.ts: -------------------------------------------------------------------------------- 1 | import { afterChange } from './pubSub'; 2 | import { initStore } from '../store'; 3 | import { Store } from './types'; 4 | import { clone, updateDeep } from './utils'; 5 | import * as ls from './ls'; 6 | import state from './state'; 7 | import { LS_KEYS } from './constants'; 8 | 9 | const timeTravelState: { 10 | currentIndex: number; 11 | history: { 12 | changedProps: string[]; 13 | store: Store; 14 | }[]; 15 | historyLimit: number; 16 | muteHistory: boolean; 17 | } = { 18 | currentIndex: 0, 19 | history: [], 20 | historyLimit: 50, 21 | muteHistory: false, 22 | }; 23 | 24 | const pruneHistory = () => { 25 | if (timeTravelState.history.length > timeTravelState.historyLimit) { 26 | const removeCount = 27 | timeTravelState.history.length - timeTravelState.historyLimit; 28 | timeTravelState.history.splice(0, removeCount); 29 | 30 | timeTravelState.currentIndex = timeTravelState.history.length - 1; 31 | } 32 | }; 33 | 34 | /** 35 | * Pick the store instance at the defined index from history 36 | * and apply it as the current store 37 | */ 38 | const applyStoreAtIndex = () => { 39 | const nextStore = timeTravelState.history[timeTravelState.currentIndex].store; 40 | 41 | timeTravelState.muteHistory = true; 42 | initStore(nextStore); 43 | timeTravelState.muteHistory = false; 44 | 45 | console.info( 46 | `Showing index ${timeTravelState.currentIndex} of ${ 47 | timeTravelState.history.length - 1 48 | }` 49 | ); 50 | }; 51 | 52 | /** 53 | * Apply a limit to the number of history items to keep in memory 54 | */ 55 | export const setHistoryLimit = (num: number) => { 56 | if (typeof num === 'number') { 57 | ls.set(LS_KEYS.RR_HISTORY_LIMIT, num); 58 | timeTravelState.historyLimit = num; 59 | pruneHistory(); 60 | 61 | if (num === 0) { 62 | console.info('Time travel is now turned off'); 63 | } 64 | } else { 65 | console.error(num, 'must be a number'); 66 | } 67 | }; 68 | 69 | /** 70 | * Return this history array. 71 | * We return the data without the proxies for readability. We do this when 72 | * retrieving rather than when putting the store in history for performance. 73 | */ 74 | export const getHistory = () => { 75 | state.redirectToNext = false; 76 | const cleanStore = updateDeep(timeTravelState.history, (item) => clone(item)); 77 | state.redirectToNext = true; 78 | 79 | return cleanStore; 80 | }; 81 | 82 | export const back = () => { 83 | if (!timeTravelState.currentIndex) { 84 | console.info('You are already at the beginning'); 85 | } else { 86 | timeTravelState.currentIndex--; 87 | applyStoreAtIndex(); 88 | } 89 | }; 90 | 91 | export const forward = () => { 92 | if (timeTravelState.currentIndex === timeTravelState.history.length - 1) { 93 | console.info('You are already at the end'); 94 | } else { 95 | timeTravelState.currentIndex++; 96 | applyStoreAtIndex(); 97 | } 98 | }; 99 | 100 | export const goTo = (index: number) => { 101 | if ( 102 | typeof index !== 'number' || 103 | index > timeTravelState.history.length - 1 || 104 | index < 0 105 | ) { 106 | console.warn( 107 | `${index} is not valid. Pick a number between 0 and ${ 108 | timeTravelState.history.length - 1 109 | }.` 110 | ); 111 | } else { 112 | timeTravelState.currentIndex = index; 113 | applyStoreAtIndex(); 114 | } 115 | }; 116 | 117 | /** 118 | * For resetting history between tests 119 | */ 120 | export const clearHistory = () => { 121 | timeTravelState.history.length = 0; 122 | timeTravelState.currentIndex = 0; 123 | }; 124 | 125 | // We wrap this in a NODE_ENV check so rollup ignores it during build 126 | // (it doesn't work this out from the NODE_ENV check in index.ts) 127 | if (process.env.NODE_ENV !== 'production') { 128 | const storedHistoryLimit = ls.get(LS_KEYS.RR_HISTORY_LIMIT); 129 | if (storedHistoryLimit && typeof storedHistoryLimit === 'number') { 130 | timeTravelState.historyLimit = storedHistoryLimit; 131 | } 132 | 133 | let pruneQueueTimeout: ReturnType; // NodeJs.Timer or number 134 | 135 | timeTravelState.history.push({ 136 | store: { ...state.store }, 137 | changedProps: ['INITIAL_STATE'], 138 | }); 139 | 140 | afterChange((e) => { 141 | // Setting historyLimit to 0 turns off time travel 142 | if (!timeTravelState.muteHistory && timeTravelState.historyLimit !== 0) { 143 | // If we're not looking at the most recent point in history, discard 144 | // everything in the future 145 | if ( 146 | timeTravelState.history.length && // False the very first time this fires 147 | timeTravelState.currentIndex !== timeTravelState.history.length - 1 148 | ) { 149 | timeTravelState.history.length = timeTravelState.currentIndex + 1; 150 | } 151 | 152 | // We shallow clone because the store OBJECT gets mutated 153 | // Don't need to deep clone since the store CONTENTS never mutate 154 | timeTravelState.history.push({ 155 | store: { ...e.store }, 156 | changedProps: e.changedProps, 157 | }); 158 | timeTravelState.currentIndex = timeTravelState.history.length - 1; 159 | 160 | clearTimeout(pruneQueueTimeout); 161 | pruneQueueTimeout = setTimeout(pruneHistory, 100); 162 | } 163 | }); 164 | } 165 | -------------------------------------------------------------------------------- /src/shared/types.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { ORIGINAL, PATH } from './constants'; 3 | 4 | /** 5 | * Define the shape of your store in your project - see README.md 6 | */ 7 | export interface Store {} 8 | 9 | /** 10 | * Extend or intersect component `props` with `WithStoreProp` 11 | * when using `connect` 12 | */ 13 | export interface WithStoreProp { 14 | store: Store; 15 | } 16 | 17 | export interface CollectorComponent extends React.Component { 18 | update(): void; 19 | _name: string; 20 | } 21 | 22 | export type AfterChangeEvent = { 23 | /** The store props that changed */ 24 | changedProps: string[]; 25 | /** The store, after the change occurred */ 26 | store: Store; 27 | /** Components updated as a result of the change */ 28 | renderedComponents: CollectorComponent[]; 29 | }; 30 | 31 | /** 32 | * The internal state of Recollect. For internal/debugging use only. 33 | */ 34 | export type State = { 35 | currentComponent: CollectorComponent | null; 36 | isBatchUpdating: boolean; 37 | isInBrowser: boolean; 38 | listeners: Map>; 39 | manualListeners: ((e: AfterChangeEvent) => void)[]; 40 | /** Records the next version of any target */ 41 | nextVersionMap: WeakMap; 42 | proxyIsMuted: boolean; 43 | /** Usually true, this will redirect reads to the latest version of the store */ 44 | redirectToNext: boolean; 45 | store: Store; 46 | }; 47 | 48 | // For clarity. The path can contain anything that can be a Map key. 49 | export type PropPath = any[]; 50 | 51 | export type UpdateInStoreProps = { 52 | target: Target; 53 | prop?: any; 54 | notifyTarget?: boolean; 55 | value?: any; 56 | /** The updater must return the result of Reflect for the active trap */ 57 | updater: (target: Target, value: any) => any; 58 | }; 59 | 60 | // This returns whatever the updater returns 61 | export type UpdateInStore = { 62 | (props: UpdateInStoreProps): any; 63 | }; 64 | 65 | /** 66 | * All proxyable objects have these shared keys. 67 | */ 68 | interface SharedBase { 69 | [PATH]?: PropPath; 70 | [ORIGINAL]?: T; 71 | [p: string]: any; 72 | [p: number]: any; 73 | // [p: symbol]: any; // one day, we'll be able to do this - https://github.com/microsoft/TypeScript/issues/1863 74 | } 75 | 76 | export type ObjWithSymbols = SharedBase & object; 77 | export type ArrWithSymbols = SharedBase & any[]; 78 | export type MapWithSymbols = SharedBase & Map; 79 | export type SetWithSymbols = SharedBase & Set; 80 | 81 | /** 82 | * A Target is any item that can be proxied 83 | */ 84 | export type Target = 85 | | ObjWithSymbols 86 | | ArrWithSymbols 87 | | MapWithSymbols 88 | | SetWithSymbols; 89 | -------------------------------------------------------------------------------- /src/store.ts: -------------------------------------------------------------------------------- 1 | import * as proxyManager from './proxyManager'; 2 | import * as pubSub from './shared/pubSub'; 3 | import * as updateManager from './updateManager'; 4 | import * as utils from './shared/utils'; 5 | import * as paths from './shared/paths'; 6 | import state from './shared/state'; 7 | import { Store, UpdateInStore } from './shared/types'; 8 | import { ORIGINAL } from './shared/constants'; 9 | 10 | /** 11 | * This is the store, as exported to the user. When the store is passed to a 12 | * component, it is shallow cloned. This leaves us free to mutate the root 13 | * level directly. 14 | */ 15 | state.store = proxyManager.createShallow({}); 16 | 17 | /** 18 | * Deep update the store, the following rules are followed: 19 | * - If updating the root level, the store object itself is mutated. 20 | * - For any other (deep) update we clone each node along the path to 21 | * the target to update (the target is cloned too). 22 | */ 23 | export const updateStore: UpdateInStore = ({ 24 | target, 25 | prop, 26 | value, 27 | notifyTarget = false, 28 | updater, 29 | }) => 30 | utils.whileMuted(() => { 31 | let result: any; 32 | 33 | // This function doesn't know anything about the prop being set. 34 | // It just finds the target (the parent of the prop) and 35 | // calls updater() with it. 36 | const targetPath = paths.get(target); 37 | 38 | // Note that if this update is a method (e.g. arr.push()) then prop can be 39 | // undefined, meaning the prop path won't be extended, and will just be 40 | // the path of the target (the array) which is correct. 41 | const propPath = paths.extend(target, prop); 42 | 43 | // If we change the length/size of an array/map/set, we will want to 44 | // trigger a render of the parent path. 45 | let targetChangedSize = false; 46 | const initialSize = utils.getSize(target); 47 | 48 | let newValue = value; 49 | 50 | // Make sure the new value is deeply wrapped in proxies, if it's a target 51 | if (utils.isTarget(newValue)) { 52 | newValue = utils.updateDeep(value, (item, thisPropPath) => { 53 | if (!utils.isTarget(item)) return item; 54 | 55 | const next = utils.clone(item); 56 | paths.addProp(next, [...propPath, ...thisPropPath]); 57 | 58 | return proxyManager.createShallow(next); 59 | }); 60 | } 61 | 62 | if (!targetPath.length) { 63 | // If the target is the store root, it's mutated in place. 64 | result = updater(state.store, newValue); 65 | targetChangedSize = utils.getSize(state.store) !== initialSize; 66 | } else { 67 | targetPath.reduce((item, thisProp, i) => { 68 | const thisValue = utils.getValue(item, thisProp); 69 | 70 | // Shallow clone this level 71 | let clone = utils.clone(thisValue); 72 | paths.addProp(clone, paths.get(thisValue)); 73 | 74 | // Wrap the clone in a proxy 75 | clone = proxyManager.createShallow(clone); 76 | 77 | // Mutate this level (swap out the original for the clone) 78 | utils.setValue(item, thisProp, clone); 79 | 80 | // If we're at the end of the path, then 'clone' is our target 81 | if (i === targetPath.length - 1) { 82 | result = updater(clone, newValue); 83 | targetChangedSize = utils.getSize(clone) !== initialSize; 84 | 85 | // We keep a reference between the original target and the clone 86 | // `target` may or may not be wrapped in a proxy (Maps and Sets are) 87 | // So we check/get the unproxied version 88 | state.nextVersionMap.set(target[ORIGINAL] || target, clone); 89 | } 90 | 91 | return clone; 92 | }, state.store); 93 | } 94 | 95 | // If the 'size' of a target changes, it's reasonable to assume that 96 | // users of the target are going to need to re-render, else use the prop 97 | const notifyPath = 98 | notifyTarget || targetChangedSize ? targetPath : propPath; 99 | 100 | updateManager.notifyByPath(notifyPath); 101 | 102 | return result; 103 | }); 104 | 105 | pubSub.onUpdateInNextStore(updateStore); 106 | 107 | /** 108 | * Executes the provided function, then updates appropriate components and calls 109 | * listeners registered with `afterChange()`. Guaranteed to only trigger one 110 | * update. The provided function must only contain synchronous code. 111 | */ 112 | export const batch = (cb: () => void) => { 113 | state.isBatchUpdating = true; 114 | cb(); 115 | state.isBatchUpdating = false; 116 | updateManager.flushUpdates(); 117 | }; 118 | 119 | /** 120 | * Empty the Recollect store and replace it with new data. 121 | */ 122 | export const initStore = (data?: Partial) => { 123 | batch(() => { 124 | utils.replaceObject(state.store, data); 125 | }); 126 | }; 127 | -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | interface Window { 2 | __RR__: any; 3 | } 4 | -------------------------------------------------------------------------------- /src/updateManager.ts: -------------------------------------------------------------------------------- 1 | import { logUpdate } from './shared/debug'; 2 | import state from './shared/state'; 3 | import * as paths from './shared/paths'; 4 | import { CollectorComponent, PropPath } from './shared/types'; 5 | import batchedUpdates from './shared/batchedUpdates'; 6 | 7 | type Queue = { 8 | components: Map>; 9 | changedPaths: Set; 10 | }; 11 | 12 | const queue: Queue = { 13 | components: new Map(), 14 | changedPaths: new Set(), 15 | }; 16 | 17 | export const flushUpdates = () => { 18 | // We batch updates here so that React will collect all setState() calls 19 | // (one for each component being updated) before triggering a render. 20 | // In other words: MANY .update() calls, ONE render. 21 | // This is subtly different to what the Recollect batch() function does - 22 | // it ensures that the listeners are only notified once for 23 | // multiple store changes 24 | // In other words: MANY store updates, ONE call to flushUpdates() 25 | batchedUpdates(() => { 26 | queue.components.forEach((propsUpdated, component) => { 27 | logUpdate(component, Array.from(propsUpdated)); 28 | 29 | component.update(); 30 | }); 31 | }); 32 | 33 | state.manualListeners.forEach((cb) => 34 | cb({ 35 | changedProps: Array.from(queue.changedPaths), 36 | renderedComponents: Array.from(queue.components.keys()), 37 | store: state.store, 38 | }) 39 | ); 40 | 41 | queue.components.clear(); 42 | queue.changedPaths.clear(); 43 | }; 44 | 45 | /** 46 | * Updates any component listening to: 47 | * - the exact propPath that has been changed. E.g. `tasks.2` 48 | * - a path further up the object tree. E.g. a component listening 49 | * on `tasks.0` need to know if `tasks = 'foo'` happens 50 | * And if the path being notified is the top level (an empty path), everyone 51 | * gets updated. 52 | */ 53 | export const notifyByPath = (propPath: PropPath) => { 54 | const pathString = paths.makeInternalString(propPath); 55 | const userFriendlyPropPath = paths.makeUserString(propPath); 56 | 57 | queue.changedPaths.add(userFriendlyPropPath); 58 | 59 | state.listeners.forEach((components, listenerPath) => { 60 | if ( 61 | pathString === '' || // Notify everyone for top-level changes 62 | pathString === listenerPath 63 | ) { 64 | components.forEach((component) => { 65 | const propsUpdated = queue.components.get(component) || new Set(); 66 | propsUpdated.add(userFriendlyPropPath); 67 | queue.components.set(component, propsUpdated); 68 | }); 69 | } 70 | }); 71 | 72 | // If we're not batch updating, flush the changes now, otherwise this 73 | // will be called when the batch is complete 74 | if (!state.isBatchUpdating) flushUpdates(); 75 | }; 76 | 77 | export const removeListenersForComponent = ( 78 | componentToRemove: CollectorComponent 79 | ) => { 80 | state.listeners.forEach((components, listenerPath) => { 81 | const filteredComponents = Array.from(components).filter( 82 | (existingComponent) => existingComponent !== componentToRemove 83 | ); 84 | 85 | if (filteredComponents.length) { 86 | state.listeners.set(listenerPath, new Set(filteredComponents)); 87 | } else { 88 | // If there are no components left listening, remove the path 89 | // For example, leaving a page will unmount a bunch of components 90 | state.listeners.delete(listenerPath); 91 | } 92 | }); 93 | }; 94 | -------------------------------------------------------------------------------- /tests/anti/hiddenProperties.test.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { store as globalStore, WithStoreProp } from '../..'; 3 | import * as testUtils from '../testUtils'; 4 | 5 | type Props = WithStoreProp & { 6 | hiddenMessage: string; 7 | }; 8 | 9 | type State = { 10 | showHiddenMessage: boolean; 11 | }; 12 | 13 | class MyComponent extends React.PureComponent { 14 | state = { 15 | showHiddenMessage: false, 16 | }; 17 | 18 | render() { 19 | const { store } = this.props; 20 | 21 | return ( 22 |
    23 | {this.state.showHiddenMessage &&

    {store.hiddenMessage}

    } 24 | 25 | 32 |
    33 | ); 34 | } 35 | } 36 | 37 | /** 38 | * This test demonstrates the situation where a store prop isn't read when 39 | * Recollect renders the component. The component later changes state to then 40 | * read from the store. But Recollect can't attribute those reads to the 41 | * component so it isn't subscribed. 42 | * So, when the store is updated, the component is not. 43 | * (The workaround is useProps, tested elsewhere.) 44 | */ 45 | it('WILL NOT update a component if the props are not used during render', () => { 46 | const { queryByText, getByText } = testUtils.collectAndRender(MyComponent); 47 | 48 | expect(queryByText('Hidden message')).not.toBeInTheDocument(); 49 | 50 | globalStore.hiddenMessage = 'Hidden message'; 51 | getByText('Show hidden message').click(); 52 | 53 | expect(queryByText('Hidden message')).toBeInTheDocument(); 54 | 55 | globalStore.hiddenMessage = 'A new message!'; 56 | 57 | // In a perfect world, the component would have been updated 58 | expect(queryByText('A new message!')).not.toBeInTheDocument(); 59 | }); 60 | 61 | /** 62 | * Same test as above, but with hooks 63 | */ 64 | it('WILL NOT update a component if the props are not used during render', () => { 65 | const { queryByText, getByText } = testUtils.collectAndRender( 66 | ({ store }: Props) => { 67 | const [showHiddenMessage, setShowHiddenMessage] = useState(false); 68 | 69 | return ( 70 |
    71 | {showHiddenMessage &&

    {store.hiddenMessage}

    } 72 | 73 | 80 |
    81 | ); 82 | } 83 | ); 84 | 85 | expect(queryByText('Hidden message')).not.toBeInTheDocument(); 86 | 87 | globalStore.hiddenMessage = 'Hidden message'; 88 | getByText('Show hidden message').click(); 89 | 90 | expect(queryByText('Hidden message')).toBeInTheDocument(); 91 | 92 | globalStore.hiddenMessage = 'A new message!'; 93 | 94 | // In a perfect world, the component would have been updated 95 | expect(queryByText('A new message!')).not.toBeInTheDocument(); 96 | }); 97 | -------------------------------------------------------------------------------- /tests/integration/README.md: -------------------------------------------------------------------------------- 1 | These integration tests operate as though Recollect were being used by a user. 2 | Some use a small task list application in the `/TaskListTest` directory, 3 | some declare their own components. 4 | -------------------------------------------------------------------------------- /tests/integration/TaskListTest/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import TaskList from './TaskList'; 3 | import Notifications from './Notifications'; 4 | import { collect, WithStoreProp } from '../../..'; 5 | 6 | interface Props extends WithStoreProp { 7 | onAppUpdate?: () => void; 8 | onTaskListUpdate?: () => void; 9 | onNotificationsUpdate?: () => void; 10 | } 11 | 12 | class App extends Component { 13 | componentDidUpdate() { 14 | if (this.props.onAppUpdate) { 15 | this.props.onAppUpdate(); 16 | } 17 | } 18 | 19 | render() { 20 | return ( 21 |
    22 | {!!this.props.store.site && ( 23 |
    {this.props.store.site.title}
    24 | )} 25 | 26 | 27 | 28 | {!!this.props.store.notifications && ( 29 | 32 | )} 33 |
    34 | ); 35 | } 36 | } 37 | 38 | export default collect(App); 39 | -------------------------------------------------------------------------------- /tests/integration/TaskListTest/Notifications.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { collect, WithStoreProp } from '../../..'; 3 | 4 | type Props = WithStoreProp & { 5 | onNotificationsUpdate?: () => void; 6 | }; 7 | 8 | class Notifications extends Component { 9 | componentDidUpdate() { 10 | if (this.props.onNotificationsUpdate) { 11 | this.props.onNotificationsUpdate(); 12 | } 13 | } 14 | 15 | render() { 16 | const { store } = this.props; 17 | 18 | return ( 19 |
    20 | {store.notifications.map((notification: string) => ( 21 |

    {notification}

    22 | ))} 23 |
    24 | ); 25 | } 26 | } 27 | 28 | export default collect(Notifications); 29 | -------------------------------------------------------------------------------- /tests/integration/TaskListTest/Task.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { store } from '../../..'; 3 | import { TaskType } from '../../testUtils'; 4 | 5 | type Props = { 6 | task: TaskType; 7 | }; 8 | 9 | const Task = React.memo(({ task }: Props) => ( 10 |
    11 | 22 | 23 | 34 |
    35 | )); 36 | 37 | export default Task; 38 | -------------------------------------------------------------------------------- /tests/integration/TaskListTest/TaskList.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import TaskList from './TaskList'; 3 | import { store } from '../../..'; 4 | import * as testUtils from '../../testUtils'; 5 | import loadTasks from './loadTasks'; 6 | 7 | it('TaskList', async () => { 8 | const { getByText, queryByText, getByLabelText } = testUtils.renderStrict( 9 | 10 | ); 11 | 12 | // it should render a loading indicator 13 | getByText('Loading...'); 14 | 15 | await loadTasks(); 16 | 17 | // it should render the tasks once loaded 18 | getByText('Task one'); 19 | 20 | // it should mark a task as done in a child component 21 | const taskOneCheckbox = getByLabelText('Task one') as HTMLInputElement; 22 | 23 | expect(taskOneCheckbox.checked).toBe(false); 24 | 25 | getByLabelText('Task one').click(); 26 | 27 | expect(taskOneCheckbox.checked).toBe(true); 28 | 29 | // the component should still be listening to other tasks. See bug: 30 | // https://github.com/davidgilbertson/react-recollect/issues/100 31 | const taskTwoCheckbox = getByLabelText('Task two') as HTMLInputElement; 32 | 33 | expect(taskTwoCheckbox.checked).toBe(false); 34 | 35 | getByLabelText('Task two').click(); 36 | 37 | expect(taskTwoCheckbox.checked).toBe(true); 38 | 39 | // it should delete a task from a child component 40 | getByText('Delete Task one').click(); 41 | 42 | expect(queryByText('Task one')).toBe(null); 43 | getByText('Task two'); 44 | getByText('Task three'); 45 | 46 | // it should add a task 47 | getByText('Add a task').click(); 48 | 49 | getByText('A new task'); 50 | 51 | // it should delete all tasks 52 | getByText('Delete all tasks').click(); 53 | 54 | getByText('You have nothing to do'); 55 | 56 | // it should accept a task added from an external source 57 | if (store.tasks) { 58 | store.tasks.push({ 59 | id: Math.random(), 60 | name: 'A task added outside the component', 61 | done: true, 62 | }); 63 | } 64 | 65 | const newTaskCheckbox = getByLabelText( 66 | 'A task added outside the component' 67 | ) as HTMLInputElement; 68 | 69 | expect(newTaskCheckbox.checked).toBe(true); 70 | 71 | newTaskCheckbox.click(); 72 | 73 | expect(newTaskCheckbox.checked).toBe(false); 74 | 75 | // it should delete the task object 76 | // OK this isn't really an integration test, 77 | // just checking that prop destruction works while I'm here 78 | getByText('Delete task object').click(); 79 | 80 | getByText('Loading...'); 81 | }); 82 | -------------------------------------------------------------------------------- /tests/integration/TaskListTest/TaskList.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import Task from './Task'; 3 | import { collect, WithStoreProp } from '../../..'; 4 | 5 | interface Props extends WithStoreProp { 6 | onTaskListUpdate?: () => void; 7 | } 8 | 9 | class TaskList extends Component { 10 | componentDidUpdate() { 11 | if (this.props.onTaskListUpdate) { 12 | this.props.onTaskListUpdate(); 13 | } 14 | } 15 | 16 | render() { 17 | const { store } = this.props; 18 | 19 | if (!store.tasks) return

    Loading...

    ; 20 | 21 | return ( 22 |
    23 | {store.tasks.length ? ( 24 |
    25 |

    {`You have ${store.tasks.length} task${ 26 | store.tasks.length === 1 ? '' : 's' 27 | }`}

    28 | 29 | {store.tasks.map((task) => ( 30 | 31 | ))} 32 | 33 | 40 |
    41 | ) : ( 42 |

    You have nothing to do

    43 | )} 44 | 57 | 58 | 65 |
    66 | ); 67 | } 68 | } 69 | 70 | export default collect(TaskList); 71 | -------------------------------------------------------------------------------- /tests/integration/TaskListTest/loadTasks.ts: -------------------------------------------------------------------------------- 1 | import { store, batch } from '../../..'; 2 | 3 | const loadTasks = () => 4 | new Promise((resolve) => { 5 | setTimeout(() => { 6 | batch(() => { 7 | if (!store.tasks) store.tasks = []; 8 | 9 | store.tasks.push( 10 | { 11 | id: 1, 12 | name: 'Task one', 13 | done: false, 14 | }, 15 | { 16 | id: 2, 17 | name: 'Task two', 18 | done: false, 19 | }, 20 | { 21 | id: 3, 22 | name: 'Task three', 23 | done: false, 24 | } 25 | ); 26 | }); 27 | 28 | resolve(); 29 | }, 100); 30 | }); 31 | 32 | export default loadTasks; 33 | -------------------------------------------------------------------------------- /tests/integration/isolation.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * This test suite hooks into the componentDidUpdate method of components to 3 | * assert that the components only update when they need to 4 | */ 5 | 6 | import React from 'react'; 7 | import * as testUtils from '../testUtils'; 8 | import App from './TaskListTest/App'; 9 | import { store } from '../..'; 10 | 11 | store.site = { 12 | title: 'The task list site', 13 | }; 14 | 15 | store.tasks = [ 16 | { 17 | id: 1, 18 | name: 'The first task in the isolation test', 19 | done: false, 20 | }, 21 | ]; 22 | 23 | store.notifications = ['You have no unread messages at all']; 24 | 25 | const props = { 26 | onAppUpdate: jest.fn(), 27 | onTaskListUpdate: jest.fn(), 28 | onNotificationsUpdate: jest.fn(), 29 | }; 30 | 31 | it('should handle isolation', () => { 32 | const { getByText } = testUtils.renderStrict(); 33 | 34 | // should render the title 35 | getByText('The task list site'); 36 | 37 | // should render a task 38 | getByText('The first task in the isolation test'); 39 | 40 | // should render a notification 41 | getByText('You have no unread messages at all'); 42 | 43 | // should re-render the App component but not the children 44 | store.site.title = 'New and improved!'; 45 | 46 | expect(props.onAppUpdate).toHaveBeenCalledTimes(1); 47 | expect(props.onTaskListUpdate).toHaveBeenCalledTimes(0); 48 | expect(props.onNotificationsUpdate).toHaveBeenCalledTimes(0); 49 | 50 | jest.resetAllMocks(); 51 | 52 | // should re-render the TaskList component only 53 | if (store.tasks) store.tasks[0].done = true; 54 | 55 | expect(props.onAppUpdate).toHaveBeenCalledTimes(0); 56 | expect(props.onTaskListUpdate).toHaveBeenCalledTimes(1); 57 | expect(props.onNotificationsUpdate).toHaveBeenCalledTimes(0); 58 | 59 | jest.resetAllMocks(); 60 | 61 | // should re-render the Notifications component only 62 | store.notifications[0] = 'You have a message now!'; 63 | 64 | expect(props.onAppUpdate).toHaveBeenCalledTimes(0); 65 | expect(props.onTaskListUpdate).toHaveBeenCalledTimes(0); 66 | expect(props.onNotificationsUpdate).toHaveBeenCalledTimes(1); 67 | }); 68 | -------------------------------------------------------------------------------- /tests/integration/listening.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { initStore, store as globalStore, WithStoreProp } from '../..'; 3 | import * as testUtils from '../testUtils'; 4 | 5 | beforeEach(() => { 6 | initStore(); 7 | }); 8 | 9 | it('should register the correct listeners', () => { 10 | initStore({ 11 | data: { 12 | myObj: { 13 | name: 'string', 14 | }, 15 | myArr: [ 16 | { 17 | id: 1, 18 | name: 'string', 19 | }, 20 | { 21 | id: 2, 22 | name: 'string', 23 | }, 24 | ], 25 | myMap: new Map([ 26 | [ 27 | 'one', 28 | { 29 | id: 1, 30 | name: 'string', 31 | }, 32 | ], 33 | [ 34 | 'two', 35 | { 36 | id: 2, 37 | name: 'string', 38 | }, 39 | ], 40 | ]), 41 | mySet: new Set(['one', 'two', 'three']), 42 | }, 43 | }); 44 | 45 | testUtils.collectAndRenderStrict(({ store }: WithStoreProp) => { 46 | return ( 47 |
    48 |

    Object

    49 |

    {store.data.myObj.name}

    50 | 51 |

    Array

    52 |
      53 | {store.data.myArr.map((item: any) => ( 54 |
    • {item.name}
    • 55 | ))} 56 |
    57 | 58 |

    Map

    59 |

    {store.data.myMap.get('one').name}

    60 | 61 |

    Set

    62 |

    {store.data.mySet.has('one')}

    63 |
    64 | ); 65 | }); 66 | 67 | expect(testUtils.getAllListeners()).toEqual([ 68 | 'data', 69 | 'data.myObj', 70 | 'data.myObj.name', 71 | 'data.myArr', 72 | 'data.myArr.0', 73 | 'data.myArr.0.id', 74 | 'data.myArr.0.name', 75 | 'data.myArr.1', 76 | 'data.myArr.1.id', 77 | 'data.myArr.1.name', 78 | 'data.myMap', 79 | 'data.myMap.one', 80 | 'data.myMap.one.name', 81 | 'data.mySet', 82 | // All set changes trigger the whole set. So no `data.mySet.one` 83 | ]); 84 | }); 85 | 86 | it('should register a listener on the store object itself', () => { 87 | const { 88 | getByText, 89 | } = testUtils.collectAndRenderStrict(({ store }: WithStoreProp) => ( 90 |
    91 | {Object.keys(store).length ? ( 92 |
    The store has stuff in it
    93 | ) : ( 94 |
    The store is empty
    95 | )} 96 |
    97 | )); 98 | 99 | expect(testUtils.getAllListeners()).toEqual(['']); 100 | 101 | getByText('The store is empty'); 102 | 103 | globalStore.anything = true; 104 | 105 | getByText('The store has stuff in it'); 106 | }); 107 | 108 | it('should register a listener on the store object with values()', () => { 109 | const { 110 | getByText, 111 | } = testUtils.collectAndRenderStrict(({ store }: WithStoreProp) => ( 112 |
    113 | {Object.values(store).includes('test') ? ( 114 |
    Has test
    115 | ) : ( 116 |
    Does not have test
    117 | )} 118 |
    119 | )); 120 | 121 | getByText('Does not have test'); 122 | 123 | globalStore.anything = 'Not test'; 124 | 125 | getByText('Does not have test'); 126 | 127 | globalStore.anything = 'test'; 128 | 129 | getByText('Has test'); 130 | }); 131 | 132 | it('should register a listener on the store object with is', () => { 133 | const { 134 | getByText, 135 | } = testUtils.collectAndRenderStrict(({ store }: WithStoreProp) => ( 136 |
    137 | {'anything' in store ? ( 138 |
    Has test
    139 | ) : ( 140 |
    Does not have test
    141 | )} 142 |
    143 | )); 144 | 145 | getByText('Does not have test'); 146 | 147 | globalStore.nothing = 'Not a thing'; 148 | 149 | getByText('Does not have test'); 150 | 151 | globalStore.anything = 'Literally anything'; 152 | 153 | getByText('Has test'); 154 | }); 155 | -------------------------------------------------------------------------------- /tests/integration/newComponentsGetNewStore.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { collect, store as globalStore, WithStoreProp } from '../..'; 3 | import * as testUtils from '../testUtils'; 4 | 5 | globalStore.hiddenMessage = ''; 6 | 7 | const TestChildComponent = collect(({ store }: WithStoreProp) => ( 8 |

    {store.hiddenMessage}

    9 | )); 10 | 11 | const TestParentComponent = collect(({ store }: WithStoreProp) => ( 12 |
    13 | {store.hiddenMessage ? :

    Details are hidden

    } 14 | 21 |
    22 | )); 23 | 24 | const { getByText } = testUtils.renderStrict(); 25 | 26 | it('should give the new version of the store to a newly mounting component', () => { 27 | getByText('Details are hidden'); 28 | 29 | getByText('Show detail').click(); 30 | 31 | getByText('New hidden message'); 32 | }); 33 | -------------------------------------------------------------------------------- /tests/integration/nodeJs.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This test suite runs outside of Jest/JSDOM to properly represent 3 | * then Node.js environment 4 | */ 5 | const assert = require('assert'); 6 | const { store, afterChange } = require('../..'); 7 | 8 | // Very sophisticated test framework. 9 | const runTest = (name, func) => { 10 | try { 11 | func(); 12 | } catch (err) { 13 | throw Error(`${name}: ${err}`); 14 | } 15 | }; 16 | 17 | runTest('The store should work without `window` present', () => { 18 | assert.equal(typeof window, 'undefined'); 19 | 20 | store.anObject = { 21 | level1: { 22 | level2: 'level 2 text', 23 | }, 24 | }; 25 | 26 | assert.deepStrictEqual(store, { 27 | anObject: { 28 | level1: { 29 | level2: 'level 2 text', 30 | }, 31 | }, 32 | }); 33 | 34 | // Now update things in the store 35 | store.anObject.level1.level2 = 'level 2 text!'; 36 | 37 | assert.deepStrictEqual(store, { 38 | anObject: { 39 | level1: { 40 | level2: 'level 2 text!', 41 | }, 42 | }, 43 | }); 44 | 45 | delete store.anObject; 46 | 47 | assert.deepStrictEqual(store, {}); 48 | }); 49 | 50 | runTest('should trigger afterChange', () => { 51 | const changes = []; 52 | 53 | afterChange((e) => { 54 | changes.push(e.changedProps[0]); 55 | }); 56 | 57 | store.prop1 = {}; 58 | store.prop1.prop2 = {}; 59 | store.prop1.prop2.foo = 'bar'; 60 | store.prop1.prop2.foo = 'baz'; 61 | 62 | assert.deepStrictEqual(changes, [ 63 | '', 64 | 'prop1', 65 | 'prop1.prop2', 66 | 'prop1.prop2.foo', 67 | ]); 68 | }); 69 | -------------------------------------------------------------------------------- /tests/integration/propsInheritance.test.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { collect, store as globalStore, WithStoreProp } from '../..'; 3 | import * as testUtils from '../testUtils'; 4 | 5 | type Props = { 6 | visibility: string; 7 | }; 8 | 9 | const ChildComponent = (props: Props) => ( 10 |
    This component should be {props.visibility}
    11 | ); 12 | 13 | // eslint-disable-next-line react/prefer-stateless-function 14 | class RawClassComponent extends Component { 15 | render() { 16 | const { store } = this.props; 17 | 18 | return ( 19 |
    20 | 27 | 28 | 31 |
    32 | ); 33 | } 34 | } 35 | 36 | const ClassComponent = collect(RawClassComponent); 37 | 38 | it('should update a child component not wrapped in collect()', () => { 39 | globalStore.clickCount = 0; 40 | 41 | const { getByText } = testUtils.renderStrict(); 42 | 43 | expect(getByText('This component should be hidden')); 44 | 45 | getByText('Click me').click(); 46 | 47 | expect(getByText('This component should be shown')); 48 | }); 49 | -------------------------------------------------------------------------------- /tests/integration/readFromTwoStores.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { store as globalStore, WithStoreProp } from '../..'; 3 | import * as testUtils from '../testUtils'; 4 | 5 | const getTitle = () => globalStore.meta.title; 6 | 7 | it('should not matter which store I read/write from', () => { 8 | globalStore.meta = { title: 'Hello' }; 9 | 10 | const { getByText } = testUtils.collectAndRenderStrict( 11 | ({ store }: WithStoreProp) => ( 12 |
    13 |

    {`${store.meta.title} from the store`}

    14 |

    {`${globalStore.meta.title} from the globalStore`}

    15 | 16 | 26 |
    27 | ) 28 | ); 29 | 30 | getByText('Change things').click(); 31 | 32 | getByText('Hello1234 from the store'); 33 | getByText('Hello1234 from the globalStore'); 34 | }); 35 | 36 | it('should subscribe to changes from the global store', () => { 37 | globalStore.meta = { title: 'Hello' }; 38 | 39 | // This is wrapped in `collect` but doesn't reference props at all 40 | const { getByText } = testUtils.collectAndRenderStrict(() => ( 41 |
    42 |

    {`${globalStore.meta.title} from the globalStore`}

    43 | 44 | 52 |
    53 | )); 54 | 55 | getByText('Hello from the globalStore'); 56 | 57 | getByText('Change things').click(); 58 | 59 | getByText('Hello24 from the globalStore'); 60 | }); 61 | -------------------------------------------------------------------------------- /tests/integration/readRemovedProperty.test.ts: -------------------------------------------------------------------------------- 1 | import { waitFor } from '@testing-library/react'; 2 | import { store } from '../..'; 3 | 4 | it('should keep a reference', () => { 5 | store.testArray = [{ name: 'David' }]; 6 | 7 | // Get a reference to the item 8 | const david = store.testArray[0]; 9 | 10 | // But now clear out the array where that object lived. 11 | store.testArray = []; 12 | 13 | expect(david).not.toBeUndefined(); 14 | 15 | expect(david.name).toBe('David'); 16 | }); 17 | 18 | it('should handle shuffling arrays about', () => { 19 | store.stackOne = [{ id: 1 }, { id: 2 }]; 20 | store.stackTwo = []; 21 | 22 | store.stackTwo.push(store.stackOne.pop()); 23 | 24 | expect(store.stackOne).toEqual([{ id: 1 }]); 25 | expect(store.stackTwo).toEqual([{ id: 2 }]); 26 | 27 | store.stackTwo.push(store.stackOne.pop()); 28 | 29 | expect(store.stackOne).toEqual([]); 30 | expect(store.stackTwo).toEqual([{ id: 2 }, { id: 1 }]); 31 | 32 | store.stackTwo.forEach((item: { id: number }) => { 33 | // eslint-disable-next-line no-param-reassign 34 | item.id *= 5; 35 | }); 36 | 37 | const [second, first] = store.stackTwo; 38 | store.stackTwo = [first, second]; 39 | expect(store.stackTwo).toEqual([{ id: 5 }, { id: 10 }]); 40 | }); 41 | 42 | it('should keep a reference async', async () => { 43 | store.testArray = [{ name: 'David' }]; 44 | const arr = store.testArray; 45 | expect(arr[0].name).toBe('David'); 46 | 47 | // Testing that the nextVersionMap isn't cleared 48 | await waitFor(() => {}); 49 | 50 | // Get a reference to the item 51 | const david = store.testArray[0]; 52 | 53 | // But now clear out the array where that object lived. 54 | store.testArray = []; 55 | 56 | expect(david).not.toBeUndefined(); 57 | 58 | expect(david.name).toBe('David'); 59 | expect(arr[0].name).toBe('David'); 60 | }); 61 | 62 | it('should keep a reference when cloning', () => { 63 | store.testArray = [{ name: 'David' }]; 64 | 65 | // Cloning this will remove the proxy and path 66 | const david = { ...store.testArray[0] }; 67 | 68 | store.testArray = []; 69 | 70 | expect(david).not.toBeUndefined(); 71 | 72 | expect(david.name).toBe('David'); 73 | }); 74 | 75 | it('should handle reference breaking', () => { 76 | store.data = { text: 'one' }; 77 | 78 | const ref = store.data; 79 | 80 | expect(ref.text).toBe('one'); 81 | 82 | // Break the reference between `ref` and `store.data` 83 | store.data = 'something else'; 84 | 85 | expect(ref.text).toBe('one'); 86 | }); 87 | 88 | it('should handle reference breaking', () => { 89 | // Same as the above test, but both `data` objects are proxiable 90 | store.data = ['one', 'two']; 91 | 92 | const ref = store.data; 93 | 94 | expect(ref[0]).toBe('one'); 95 | 96 | // Break the reference between `ref` and `data` 97 | store.data = { three: 'four' }; 98 | 99 | expect(ref[0]).toBe('one'); 100 | expect(ref.three).toBeUndefined(); 101 | }); 102 | 103 | it('should not allow a clone to update store', () => { 104 | store.data = { text: 'one' }; 105 | 106 | const dataClone = { ...store.data }; 107 | 108 | dataClone.text = 'two'; 109 | 110 | expect(dataClone.text).toBe('two'); // clone changes 111 | expect(store.data.text).toBe('one'); // store does not 112 | 113 | // But it will still deep update the store 114 | const storeClone = { ...store }; 115 | 116 | // This is just a shallow clone 117 | expect(storeClone.data).toBe(store.data); 118 | 119 | storeClone.data.text = 'three'; 120 | 121 | expect(storeClone.data.text).toBe('three'); 122 | expect(store.data.text).toBe('three'); 123 | }); 124 | 125 | it('should sort a clone separately', () => { 126 | store.tasks = [ 127 | { 128 | id: 2, 129 | name: 'Task 2', 130 | }, 131 | { 132 | id: 1, 133 | name: 'Task 1', 134 | }, 135 | { 136 | id: 0, 137 | name: 'Task 0', 138 | }, 139 | ]; 140 | 141 | const clonedTasks = store.tasks.slice(); 142 | 143 | // Slicing doesn't clone the contents 144 | expect(store.tasks[0]).toBe(clonedTasks[0]); 145 | 146 | clonedTasks.sort((a, b) => a.id - b.id); 147 | 148 | // Sorting sorts the clone only 149 | expect(store.tasks[0]).toBe(clonedTasks[2]); 150 | 151 | clonedTasks[2].name = 'Task 2 (modified)'; 152 | 153 | // Recollect v4 used to update based on path, so the below would fail 154 | // This test asserts that path doesn't matter 155 | expect(store.tasks[0].name).toBe('Task 2 (modified)'); 156 | expect(store.tasks[1].name).toBe('Task 1'); 157 | expect(store.tasks[2].name).toBe('Task 0'); 158 | 159 | expect(clonedTasks[0].name).toBe('Task 0'); 160 | expect(clonedTasks[1].name).toBe('Task 1'); 161 | expect(clonedTasks[2].name).toBe('Task 2 (modified)'); 162 | 163 | store.tasks.sort((a, b) => a.id - b.id); 164 | 165 | expect(store.tasks[0].name).toBe('Task 0'); 166 | expect(store.tasks[1].name).toBe('Task 1'); 167 | expect(store.tasks[2].name).toBe('Task 2 (modified)'); 168 | 169 | expect(clonedTasks[0].name).toBe('Task 0'); 170 | expect(clonedTasks[1].name).toBe('Task 1'); 171 | expect(clonedTasks[2].name).toBe('Task 2 (modified)'); 172 | }); 173 | -------------------------------------------------------------------------------- /tests/integration/renderBatching.test.tsx: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line max-classes-per-file 2 | import React from 'react'; 3 | import * as testUtils from '../testUtils'; 4 | import { collect, store as globalStore, WithStoreProp } from '../..'; 5 | 6 | declare module '../..' { 7 | interface Store { 8 | propNum: number; 9 | } 10 | } 11 | 12 | it('should combine updates into a single render', () => { 13 | type ChildProps = WithStoreProp & { 14 | fromParent: string; 15 | }; 16 | 17 | const Child = collect( 18 | class ChildComponentRaw extends React.Component { 19 | renderCount = 1; 20 | 21 | render() { 22 | return ( 23 |
    24 |

    Child

    25 |

    {`Child render count: ${this.renderCount++}`}

    26 |

    {`Child value: ${this.props.store.value}`}

    27 |

    {`Child value fromParent: ${this.props.fromParent}`}

    28 |
    29 | ); 30 | } 31 | } 32 | ); 33 | 34 | class Parent extends React.Component { 35 | renderCount = 1; 36 | 37 | render() { 38 | return ( 39 |
    40 |

    Parent

    41 |

    {`Parent render count: ${this.renderCount++}`}

    42 |

    {`Parent value: ${this.props.store.value}`}

    43 | 44 | 45 |
    46 | ); 47 | } 48 | } 49 | 50 | const { getByText } = testUtils.collectAndRender(Parent); 51 | 52 | getByText('Parent render count: 1'); 53 | getByText('Child render count: 1'); 54 | 55 | // Simulate external change (not from within a React-handled click event) 56 | globalStore.value = 'x'; 57 | // Changing the store will trigger: 58 | // - an update of (because it's collected), 59 | // which in turn would trigger an update of (prop passed down) 60 | // - an update of (because it's collected) 61 | 62 | // If we didn't batch multiple component updates into a single render, 63 | // we'd get two renders 64 | getByText('Parent render count: 2'); 65 | getByText('Parent value: x'); 66 | getByText('Child render count: 2'); // Just 1 more. Good. 67 | getByText('Child value: x'); 68 | getByText('Child value fromParent: x!'); 69 | 70 | globalStore.value = 'y'; 71 | 72 | getByText('Parent render count: 3'); 73 | getByText('Parent value: y'); 74 | getByText('Child render count: 3'); 75 | getByText('Child value: y'); 76 | getByText('Child value fromParent: y!'); 77 | }); 78 | -------------------------------------------------------------------------------- /tests/integration/setStoreTwiceInOnClick.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { collect, store as globalStore, WithStoreProp } from '../..'; 3 | import * as testUtils from '../testUtils'; 4 | 5 | const TestComponent = collect(({ store }: WithStoreProp) => ( 6 |
    7 | {store.tasks && 8 | store.tasks.map((task) =>
    {task.name}
    )} 9 | 10 | {!!store.tasks ||

    You have no tasks

    } 11 | 12 | 19 | 20 | 36 | 37 | {!!store.page && ( 38 | <> 39 | 48 |
    {`Status: ${store.page.status}`}
    49 | 50 | )} 51 |
    52 | )); 53 | 54 | it('should allow the user to set the store twice in one callback without a re-render', () => { 55 | const { getByText } = testUtils.renderStrict(); 56 | getByText('You have no tasks'); 57 | 58 | // This click will do store.tasks = [], which is added to the store 59 | // Immediately followed by store.tasks.push(). 60 | // That second call should be routed to the next store 61 | getByText('Add a task').click(); 62 | 63 | getByText('A new task'); 64 | 65 | getByText('Delete all tasks').click(); 66 | 67 | getByText('You have no tasks'); 68 | 69 | getByText('Add a task').click(); 70 | 71 | getByText('A new task'); 72 | }); 73 | 74 | it('should increment string', () => { 75 | globalStore.page = { 76 | status: 'Happy', 77 | }; 78 | 79 | const { getByText } = testUtils.renderStrict(); 80 | 81 | getByText('Pump the jams').click(); 82 | 83 | getByText('Status: Happy!!!'); 84 | }); 85 | -------------------------------------------------------------------------------- /tests/integration/sharedStoreProps.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { initStore, collect, WithStoreProp } from '../..'; 3 | import * as testUtils from '../testUtils'; 4 | 5 | beforeEach(() => { 6 | initStore(); 7 | }); 8 | 9 | /** 10 | * Passing anything from the store into a collected component can be trouble. 11 | * @see https://github.com/davidgilbertson/react-recollect/issues/102 12 | */ 13 | it('should error when sharing between collected components', () => { 14 | initStore({ 15 | tasks: [ 16 | { 17 | id: 1, 18 | name: 'Task one', 19 | }, 20 | ], 21 | }); 22 | 23 | const ChildComponent = ({ task }: any) =>
    {task.name}
    ; 24 | const ChildComponentCollected = collect(ChildComponent); 25 | 26 | // Shouldn't matter how many levels deep the prop is passed 27 | const MiddleComponent = ({ task }: any) => ( 28 |
    29 | 30 |
    31 | ); 32 | 33 | const ParentComponent = collect(({ store }: WithStoreProp) => ( 34 |
    35 | {!!store.tasks && 36 | store.tasks.map((task) => ( 37 | 38 | ))} 39 |
    40 | )); 41 | 42 | const errorMessage = testUtils.expectToLogError(() => { 43 | testUtils.renderStrict(); 44 | }); 45 | 46 | // Just a partial match... 47 | expect(errorMessage).toMatch( 48 | 'Either remove the collect() wrapper from , or remove the "task" prop' 49 | ); 50 | }); 51 | 52 | it('can bypass sharing warning by shallow cloning', () => { 53 | initStore({ 54 | tasks: [ 55 | { 56 | id: 1, 57 | name: 'Task one', 58 | }, 59 | ], 60 | }); 61 | 62 | const ChildComponent = ({ task }: any) =>
    {task.name}
    ; 63 | const ChildComponentCollected = collect(ChildComponent); 64 | 65 | const ParentComponent = collect(({ store }: WithStoreProp) => ( 66 |
    67 | {!!store.tasks && 68 | store.tasks.map((task) => ( 69 | // Shallow cloning task here 'detaches' it from the store 70 | 71 | ))} 72 |
    73 | )); 74 | 75 | const { getByText } = testUtils.renderStrict(); 76 | 77 | getByText('Task one'); 78 | }); 79 | -------------------------------------------------------------------------------- /tests/integration/siblingIsolation.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import { collect, store as globalStore, WithStoreProp } from '../..'; 4 | 5 | let renderCountOne = 0; 6 | let renderCountTwo = 0; 7 | let renderCountThree = 0; 8 | 9 | const One = ({ store }: WithStoreProp) => { 10 | renderCountOne++; 11 | return
    {`This is ${store.areas.one.name}`}
    ; 12 | }; 13 | const OneCollected = collect(One); 14 | 15 | const Two = ({ store }: WithStoreProp) => { 16 | renderCountTwo++; 17 | return
    {`This is ${store.areas.two.name}`}
    ; 18 | }; 19 | const TwoCollected = collect(Two); 20 | 21 | const Three = ({ store }: WithStoreProp) => { 22 | renderCountThree++; 23 | return
    {`This is ${store.areas.three.name}`}
    ; 24 | }; 25 | const ThreeCollected = collect(Three); 26 | 27 | const Parent = () => ( 28 |
    29 | 30 | 31 | 32 |
    33 | ); 34 | 35 | globalStore.areas = { 36 | one: { 37 | name: 'Area one', 38 | }, 39 | two: { 40 | name: 'Area two', 41 | }, 42 | three: { 43 | name: 'Area three', 44 | }, 45 | }; 46 | 47 | it('should handle isolation', () => { 48 | const { getByText } = render(); 49 | expect(renderCountOne).toBe(1); 50 | expect(renderCountTwo).toBe(1); 51 | expect(renderCountThree).toBe(1); 52 | 53 | // should render the title 54 | getByText('This is Area one'); 55 | getByText('This is Area two'); 56 | getByText('This is Area three'); 57 | 58 | globalStore.areas.one.name = 'A new place'; 59 | 60 | getByText('This is A new place'); 61 | getByText('This is Area two'); 62 | getByText('This is Area three'); 63 | 64 | expect(renderCountOne).toBe(2); 65 | 66 | // These two won't update 67 | expect(renderCountTwo).toBe(1); 68 | expect(renderCountThree).toBe(1); 69 | }); 70 | -------------------------------------------------------------------------------- /tests/integration/storeVPlainObject.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This test suite is a collection of the ways in which the Recollect store 3 | * behaves differently to a plain JavaScript object. 4 | */ 5 | import { store } from '../..'; 6 | 7 | it('will create a new object', () => { 8 | store.data = { 9 | foo: 'bar', 10 | }; 11 | 12 | const originalData = store.data; 13 | 14 | store.data.foo = 'baz'; 15 | 16 | // These two are now separate objects. This is required for React to work 17 | expect(originalData).not.toBe(store.data); 18 | 19 | // But when you read any value from them, they will contain the same data 20 | expect(originalData).toEqual(store.data); 21 | expect(originalData.foo).toEqual('baz'); 22 | expect(store.data.foo).toEqual('baz'); 23 | }); 24 | -------------------------------------------------------------------------------- /tests/integration/types.test.ts: -------------------------------------------------------------------------------- 1 | import { store, initStore } from '../..'; 2 | 3 | beforeEach(() => { 4 | initStore(); 5 | }); 6 | 7 | const getType = (item: any) => 8 | Object.prototype.toString.call(item).replace('[object ', '').replace(']', ''); 9 | 10 | it('should should have the correct type', () => { 11 | store.array = []; 12 | store.date = new Date(); 13 | store.boolean = false; 14 | store.map = new Map(); 15 | store.null = null; 16 | store.number = 77; 17 | store.object = {}; 18 | store.regExp = /cats/; 19 | store.set = new Set(); 20 | store.string = 'string'; 21 | 22 | expect(typeof store.array).toBe('object'); 23 | expect(typeof store.date).toBe('object'); 24 | expect(typeof store.boolean).toBe('boolean'); 25 | expect(typeof store.map).toBe('object'); 26 | expect(typeof store.null).toBe('object'); 27 | expect(typeof store.number).toBe('number'); 28 | expect(typeof store.object).toBe('object'); 29 | expect(typeof store.regExp).toBe('object'); 30 | expect(typeof store.set).toBe('object'); 31 | expect(typeof store.string).toBe('string'); 32 | 33 | expect(getType(store.array)).toBe('Array'); 34 | expect(getType(store.date)).toBe('Date'); 35 | expect(getType(store.boolean)).toBe('Boolean'); 36 | expect(getType(store.map)).toBe('Map'); 37 | expect(getType(store.null)).toBe('Null'); 38 | expect(getType(store.number)).toBe('Number'); 39 | expect(getType(store.object)).toBe('Object'); 40 | expect(getType(store.regExp)).toBe('RegExp'); 41 | expect(getType(store.set)).toBe('Set'); 42 | expect(getType(store.string)).toBe('String'); 43 | 44 | expect(Array.isArray(store.array)).toBe(true); 45 | 46 | expect(store.map instanceof Map).toBe(true); 47 | expect(store.set instanceof Set).toBe(true); 48 | }); 49 | 50 | it('should should have typeof undefined', () => { 51 | expect(store.foo).toBeUndefined(); 52 | expect(typeof store.foo).toBe('undefined'); 53 | 54 | store.foo = 'bar'; 55 | expect(typeof store.foo).not.toBe('undefined'); 56 | 57 | delete store.foo; 58 | expect(typeof store.foo).toBe('undefined'); 59 | }); 60 | -------------------------------------------------------------------------------- /tests/integration/unCollectedChildren.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Child components that render items in an array should not need to be wrapped in collect() 3 | */ 4 | import React from 'react'; 5 | import { render } from '@testing-library/react'; 6 | import { 7 | afterChange, 8 | collect, 9 | store as globalStore, 10 | WithStoreProp, 11 | } from '../..'; 12 | import { TaskType } from '../testUtils'; 13 | 14 | globalStore.tasks = [ 15 | { id: 0, name: 'task 0', done: false }, 16 | { id: 1, name: 'task 1', done: false }, 17 | ]; 18 | 19 | const handleChange = jest.fn(); 20 | let taskListRenderCount = 0; 21 | let taskRenderCount = 0; 22 | 23 | afterChange(handleChange); 24 | 25 | afterEach(() => { 26 | handleChange.mockClear(); 27 | }); 28 | 29 | type Props = { 30 | task: TaskType; 31 | }; 32 | 33 | const Task = React.memo(({ task }: Props) => { 34 | taskRenderCount++; 35 | 36 | return ( 37 | 49 | ); 50 | }); 51 | 52 | const TaskList = ({ store }: WithStoreProp) => { 53 | taskListRenderCount++; 54 | 55 | return ( 56 |
    57 | {store.tasks && 58 | store.tasks.map((task) => )} 59 |
    60 | ); 61 | }; 62 | 63 | const CollectedTaskList = collect(TaskList); 64 | 65 | it('should update a parent component when a prop is changed on a child component', () => { 66 | const { getByText, getByLabelText } = render(); 67 | const getInputByLabelText = (text: string) => 68 | getByLabelText(text) as HTMLInputElement; 69 | 70 | expect(taskListRenderCount).toBe(1); 71 | expect(taskRenderCount).toBe(2); 72 | 73 | getByText('task 0'); 74 | 75 | expect(getInputByLabelText('task 0').checked).toBe(false); 76 | 77 | getByLabelText('task 0').click(); // in the 78 | 79 | const changeEvent = handleChange.mock.calls[0][0]; 80 | expect(changeEvent.changedProps).toEqual(['tasks.0.done']); 81 | expect(changeEvent.renderedComponents[0]._name).toBe('TaskList'); 82 | 83 | expect(taskListRenderCount).toBe(2); 84 | expect(taskRenderCount).toBe(3); // only one task should update 85 | 86 | expect(getInputByLabelText('task 0').checked).toBe(true); 87 | }); 88 | -------------------------------------------------------------------------------- /tests/objectTypes/array.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { initStore, store as globalStore, WithStoreProp } from '../..'; 3 | import * as testUtils from '../testUtils'; 4 | 5 | let renderCount: number; 6 | let taskNumber: number; 7 | let taskId: number; 8 | const log = jest.fn(); 9 | 10 | beforeEach(() => { 11 | initStore({ 12 | tasks: [], 13 | }); 14 | renderCount = 0; 15 | taskNumber = 1; 16 | taskId = -1; 17 | log.mockReset(); 18 | }); 19 | 20 | type Props = { 21 | task: testUtils.TaskType; 22 | }; 23 | 24 | it('should operate on arrays', () => { 25 | const Task = (props: Props) =>
    {props.task.name}
    ; 26 | 27 | const { getByText, queryByText } = testUtils.collectAndRender( 28 | ({ store }: WithStoreProp) => { 29 | renderCount++; 30 | 31 | return ( 32 |
    33 | 47 | 48 | 55 | 56 | 63 | 64 | {!!store.tasks && !!store.tasks.length && ( 65 | <> 66 |

    Task list

    67 | 68 | {store.tasks.map((task) => ( 69 | 70 | ))} 71 | 72 | )} 73 |
    74 | ); 75 | } 76 | ); 77 | 78 | expect(renderCount).toBe(1); 79 | 80 | expect(queryByText('Task list')).toBeNull(); 81 | 82 | // should handle adding an item to an array 83 | getByText('Add task').click(); 84 | 85 | expect(renderCount).toBe(2); 86 | expect(getByText('Task list')); 87 | expect(getByText('Task number 1')); 88 | // Reading immediately should already have the new length property 89 | expect(log).toHaveBeenCalledWith(1); 90 | expect(globalStore.tasks && globalStore.tasks.length).toBe(1); 91 | 92 | // should handle removing an item from an array 93 | getByText('Add task').click(); 94 | getByText('Add task').click(); 95 | expect(renderCount).toBe(4); 96 | 97 | expect(getByText('Task number 2')); 98 | expect(getByText('Task number 3')); 99 | 100 | getByText('Remove last task').click(); 101 | expect(renderCount).toBe(5); 102 | 103 | expect(queryByText('Task number 3')).toBeNull(); 104 | 105 | // should handle deleting an entire array 106 | expect(getByText('Task list')); 107 | 108 | getByText('Remove all tasks').click(); 109 | expect(renderCount).toBe(6); 110 | 111 | expect(queryByText('Task list')).toBeNull(); 112 | }); 113 | 114 | it('should push then update', () => { 115 | globalStore.arr = []; 116 | globalStore.arr.push({ name: 'A two', done: false }); 117 | globalStore.arr.unshift({ name: 'Task one', done: false }); 118 | globalStore.arr[0].done = true; 119 | 120 | expect(globalStore.arr).toEqual([ 121 | { name: 'Task one', done: true }, 122 | { name: 'A two', done: false }, 123 | ]); 124 | }); 125 | 126 | /** 127 | * This tests that array mutator methods behave as expected, and that they 128 | * only re-render the component once each. 129 | */ 130 | it('should update once for array mutator methods', () => { 131 | globalStore.arr = []; 132 | type Props = { 133 | store: { 134 | arr: number[]; 135 | }; 136 | }; 137 | 138 | renderCount = 0; 139 | 140 | const { getByText } = testUtils.collectAndRender(({ store }: Props) => { 141 | renderCount++; 142 | 143 | return
    {`Array: ${store.arr?.join(', ') || 'none'}`}
    ; 144 | }); 145 | 146 | expect(testUtils.getAllListeners()).toEqual(['arr']); 147 | 148 | getByText('Array: none'); 149 | expect(renderCount).toBe(1); 150 | renderCount = 0; 151 | let result; 152 | 153 | const prevArray = globalStore.arr; 154 | globalStore.arr.push(22, 11, 33, 77, 44, 55, 66); 155 | 156 | // Confirm that the array is cloned 157 | expect(prevArray).not.toBe(globalStore.arr); 158 | 159 | getByText('Array: 22, 11, 33, 77, 44, 55, 66'); 160 | expect(renderCount).toBe(1); 161 | 162 | renderCount = 0; 163 | result = globalStore.arr.sort(); 164 | getByText('Array: 11, 22, 33, 44, 55, 66, 77'); 165 | expect(result).toEqual([11, 22, 33, 44, 55, 66, 77]); 166 | expect(renderCount).toBe(1); 167 | 168 | renderCount = 0; 169 | result = globalStore.arr.sort((a: number, b: number) => b - a); 170 | getByText('Array: 77, 66, 55, 44, 33, 22, 11'); 171 | expect(result).toEqual([77, 66, 55, 44, 33, 22, 11]); 172 | expect(renderCount).toBe(1); 173 | 174 | renderCount = 0; 175 | result = globalStore.arr.reverse(); 176 | getByText('Array: 11, 22, 33, 44, 55, 66, 77'); 177 | expect(result).toEqual([11, 22, 33, 44, 55, 66, 77]); 178 | expect(renderCount).toBe(1); 179 | 180 | renderCount = 0; 181 | result = globalStore.arr.pop(); 182 | getByText('Array: 11, 22, 33, 44, 55, 66'); 183 | expect(result).toBe(77); 184 | expect(renderCount).toBe(1); 185 | 186 | renderCount = 0; 187 | result = globalStore.arr.shift(); 188 | getByText('Array: 22, 33, 44, 55, 66'); 189 | expect(result).toBe(11); 190 | expect(renderCount).toBe(1); 191 | 192 | renderCount = 0; 193 | result = globalStore.arr.splice(0, 2); 194 | getByText('Array: 44, 55, 66'); 195 | expect(result).toEqual([22, 33]); 196 | expect(renderCount).toBe(1); 197 | 198 | renderCount = 0; 199 | result = globalStore.arr.unshift(11, 22, 33); 200 | getByText('Array: 11, 22, 33, 44, 55, 66'); 201 | expect(result).toBe(6); 202 | expect(renderCount).toBe(1); 203 | 204 | renderCount = 0; 205 | result = globalStore.arr.copyWithin(0, -3, -2); 206 | getByText('Array: 44, 22, 33, 44, 55, 66'); 207 | expect(result).toEqual([44, 22, 33, 44, 55, 66]); 208 | expect(renderCount).toBe(1); 209 | 210 | // Here's an invalid copyWithin. It's fine that this updates 211 | renderCount = 0; 212 | result = globalStore.arr.copyWithin(0, 2, 2); 213 | getByText('Array: 44, 22, 33, 44, 55, 66'); 214 | expect(result).toEqual([44, 22, 33, 44, 55, 66]); 215 | expect(renderCount).toBe(1); 216 | 217 | renderCount = 0; 218 | result = globalStore.arr.fill(11, 1, 3); 219 | getByText('Array: 44, 11, 11, 44, 55, 66'); 220 | expect(result).toEqual([44, 11, 11, 44, 55, 66]); 221 | expect(renderCount).toBe(1); 222 | 223 | renderCount = 0; 224 | globalStore.arr.length = 0; 225 | getByText('Array: none'); 226 | expect(result).toEqual([]); 227 | expect(renderCount).toBe(1); 228 | }); 229 | -------------------------------------------------------------------------------- /tests/objectTypes/set.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | afterChange, 4 | initStore, 5 | store as globalStore, 6 | WithStoreProp, 7 | } from '../..'; 8 | import * as testUtils from '../testUtils'; 9 | 10 | let renderCount: number; 11 | 12 | const handleChange = jest.fn(); 13 | afterChange(handleChange); 14 | 15 | beforeEach(() => { 16 | initStore({ 17 | mySet: new Set(['one']), 18 | }); 19 | 20 | renderCount = 0; 21 | handleChange.mockClear(); 22 | }); 23 | 24 | it('should operate on a Set', () => { 25 | const { getByText, getByTestId } = testUtils.collectAndRender( 26 | ({ store }: WithStoreProp) => { 27 | renderCount++; 28 | 29 | return ( 30 |
    31 |

    Set() stuff

    32 |

    Size: {store.mySet.size}

    33 |

    34 | Has one?: {store.mySet.has('one').toString()} 35 |

    36 | 37 | 44 | 45 | 52 | 53 |

    54 | Has two?: {store.mySet.has('two').toString()} 55 |

    56 | 57 | 64 | 65 |

    66 | {Array.from(store.mySet.keys()).join(', ')} 67 |

    68 | 69 | 77 |
    78 | ); 79 | } 80 | ); 81 | 82 | expect(renderCount).toBe(1); 83 | expect(getByTestId('set-size')).toHaveTextContent('Size: 1'); 84 | expect(getByTestId('set-one')).toHaveTextContent('Has one?: true'); 85 | expect(getByTestId('set-two')).toHaveTextContent('Has two?: false'); 86 | 87 | getByText('Add two to set').click(); 88 | expect(renderCount).toBe(2); 89 | expect(globalStore.mySet.size).toBe(2); 90 | expect(getByTestId('set-size')).toHaveTextContent('Size: 2'); 91 | expect(getByTestId('set-keys')).toHaveTextContent('one, two'); 92 | expect(getByTestId('set-two')).toHaveTextContent('Has two?: true'); 93 | 94 | // Should not trigger another render 95 | getByText('Add two to set').click(); 96 | expect(renderCount).toBe(2); 97 | 98 | getByText('Delete two from set').click(); 99 | expect(renderCount).toBe(3); 100 | expect(globalStore.mySet.size).toBe(1); 101 | expect(getByTestId('set-size')).toHaveTextContent('Size: 1'); 102 | expect(getByTestId('set-keys')).toHaveTextContent('one'); 103 | expect(getByTestId('set-two')).toHaveTextContent('Has two?: false'); 104 | 105 | getByText('Clear set').click(); 106 | expect(renderCount).toBe(4); 107 | expect(globalStore.mySet.size).toBe(0); 108 | expect(getByTestId('set-size')).toHaveTextContent('Size: 0'); 109 | expect(getByTestId('set-keys')).toHaveTextContent(''); 110 | expect(getByTestId('set-one')).toHaveTextContent('Has one?: false'); 111 | expect(getByTestId('set-two')).toHaveTextContent('Has two?: false'); 112 | 113 | // Shouldn't render again 114 | getByText('Clear set').click(); 115 | expect(renderCount).toBe(4); 116 | 117 | getByText('Add two things to set').click(); 118 | expect(renderCount).toBe(5); // just one render 119 | expect(globalStore.mySet.size).toBe(2); 120 | expect(getByTestId('set-size')).toHaveTextContent('Size: 2'); 121 | expect(getByTestId('set-keys')).toHaveTextContent('three, four'); 122 | 123 | // TODO (davidg): what about creating an object, putting it in a set, then mutating 124 | // the object? 125 | }); 126 | -------------------------------------------------------------------------------- /tests/react/basicClassComponent.test.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { collect, store, WithStoreProp } from '../..'; 3 | import * as testUtils from '../testUtils'; 4 | 5 | // eslint-disable-next-line react/prefer-stateless-function 6 | class RawClassComponent extends Component { 7 | render() { 8 | return ( 9 |
    10 |

    {this.props.store.title}

    11 |

    Button was pressed {this.props.store.clickCount} times

    12 | 19 |
    20 | ); 21 | } 22 | } 23 | 24 | const ClassComponent = collect(RawClassComponent); 25 | 26 | store.title = 'The initial title'; 27 | store.clickCount = 3; 28 | 29 | it('should render and update the title', () => { 30 | const { getByText } = testUtils.renderStrict(); 31 | 32 | expect(getByText('The initial title')); 33 | 34 | // External change 35 | store.title = 'The updated title'; 36 | 37 | expect(getByText('The updated title')); 38 | }); 39 | 40 | it('should render and update the click count', () => { 41 | const { getByText } = testUtils.renderStrict(); 42 | 43 | expect(getByText('Button was pressed 3 times')); 44 | 45 | getByText('Click me').click(); 46 | 47 | expect(getByText('Button was pressed 4 times')); 48 | }); 49 | -------------------------------------------------------------------------------- /tests/react/basicFunctionalComponent.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { store, WithStoreProp } from '../..'; 3 | import * as testUtils from '../testUtils'; 4 | 5 | it('should render the title', () => { 6 | store.title = 'The initial title'; 7 | 8 | const { 9 | getByText, 10 | } = testUtils.collectAndRenderStrict((props: WithStoreProp) => ( 11 |

    {props.store.title}

    12 | )); 13 | 14 | getByText('The initial title'); 15 | 16 | store.title = 'The updated title'; 17 | 18 | getByText('The updated title'); 19 | }); 20 | -------------------------------------------------------------------------------- /tests/react/componentDidMount.test.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-classes-per-file */ 2 | import React, { Component } from 'react'; 3 | import { collect, WithStoreProp } from '../..'; 4 | import * as testUtils from '../testUtils'; 5 | 6 | const TestComponentBad = collect( 7 | class extends Component { 8 | componentDidMount() { 9 | this.props.store.loading = true; 10 | 11 | setTimeout(() => { 12 | this.props.store.loading = false; 13 | }, 100); 14 | } 15 | 16 | render() { 17 | if (this.props.store.loading) return

    Loading...

    ; 18 | 19 | return

    Loaded

    ; 20 | } 21 | } 22 | ); 23 | 24 | const TestComponentGood = collect( 25 | class extends Component { 26 | componentDidMount() { 27 | setTimeout(() => { 28 | this.props.store.loading = true; 29 | 30 | setTimeout(() => { 31 | this.props.store.loading = false; 32 | }, 100); 33 | }); 34 | } 35 | 36 | render() { 37 | if (this.props.store.loading) return

    Loading...

    ; 38 | 39 | return

    Loaded

    ; 40 | } 41 | } 42 | ); 43 | 44 | it('should fail if setting the state during mounting', () => { 45 | testUtils.expectToLogError(() => { 46 | testUtils.renderStrict(); 47 | }); 48 | }); 49 | 50 | it('should set loading state after mounting', async () => { 51 | const { findByText } = testUtils.renderStrict(); 52 | 53 | await findByText('Loading...'); 54 | 55 | await findByText('Loaded'); 56 | }); 57 | -------------------------------------------------------------------------------- /tests/react/componentsInTheStore.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { initStore, WithStoreProp } from '../..'; 3 | import * as testUtils from '../testUtils'; 4 | 5 | it('should store a component', () => { 6 | initStore({ 7 | components: { 8 | Header: (props: any) =>

    {props.title}

    , 9 | }, 10 | }); 11 | 12 | const { getByText } = testUtils.collectAndRenderStrict( 13 | ({ store }: WithStoreProp) => { 14 | const { Header } = store.components; 15 | 16 | return ( 17 |
    18 |
    19 |
    20 | ); 21 | } 22 | ); 23 | 24 | getByText('Page one'); 25 | }); 26 | 27 | it('will not store a component instance or element', () => { 28 | initStore({ 29 | components: { 30 | header:

    I am a header

    , 31 | }, 32 | }); 33 | 34 | const consoleError = testUtils.expectToLogError(() => { 35 | testUtils.collectAndRenderStrict(({ store }: WithStoreProp) => ( 36 |
    {store.components.header}
    37 | )); 38 | }); 39 | 40 | // I don't know why exactly, but it seems that React sets 'validated' 41 | expect(consoleError[0]).toMatch( 42 | `You are attempting to modify the store during a render cycle. (You're setting "validated" to "true" somewhere)` 43 | ); 44 | }); 45 | -------------------------------------------------------------------------------- /tests/react/forwardRefClass.test.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-classes-per-file */ 2 | import React, { Component } from 'react'; 3 | import { collect, WithStoreProp } from '../..'; 4 | import * as testUtils from '../testUtils'; 5 | 6 | interface Props extends WithStoreProp { 7 | defaultValue: string; 8 | inputRef: React.Ref; 9 | } 10 | 11 | class RawCleverInput extends React.PureComponent { 12 | render() { 13 | const { props } = this; 14 | return ( 15 | 19 | ); 20 | } 21 | } 22 | 23 | const CleverInput = collect(RawCleverInput); 24 | 25 | class ComponentWithRef extends Component { 26 | inputRef = React.createRef(); 27 | 28 | render() { 29 | return ( 30 |
    31 | 40 | 41 | 42 |
    43 | ); 44 | } 45 | } 46 | 47 | const { getByText, getByLabelText } = testUtils.renderStrict( 48 | 49 | ); 50 | 51 | it('should empty the input when the button is clicked', () => { 52 | const getInputByLabelText = (text: string) => 53 | getByLabelText(text) as HTMLInputElement; 54 | 55 | expect(getInputByLabelText('The input').value).toBe('some text'); 56 | getInputByLabelText('The input').value = 'some different text'; 57 | expect(getInputByLabelText('The input').value).toBe('some different text'); 58 | 59 | getByText('Empty the input').click(); 60 | 61 | expect(getInputByLabelText('The input').value).toBe('X'); 62 | }); 63 | -------------------------------------------------------------------------------- /tests/react/forwardRefFc.test.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { collect, WithStoreProp } from '../..'; 3 | import * as testUtils from '../testUtils'; 4 | 5 | type Props = WithStoreProp & { 6 | defaultValue: string; 7 | inputRef: React.Ref; 8 | }; 9 | 10 | const CollectedWithRef = collect((props: Props) => ( 11 | 15 | )); 16 | 17 | class ComponentWithRef extends Component { 18 | inputRef = React.createRef(); 19 | 20 | render() { 21 | return ( 22 |
    23 | 30 | 31 | 32 |
    33 | ); 34 | } 35 | } 36 | 37 | const { getByText, getByLabelText } = testUtils.renderStrict( 38 | 39 | ); 40 | 41 | const getInputByLabelText = (text: string) => 42 | getByLabelText(text) as HTMLInputElement; 43 | 44 | it('should empty the input when the button is clicked', () => { 45 | expect(getInputByLabelText('The input').value).toBe('some text'); 46 | getInputByLabelText('The input').value = 'some different text'; 47 | expect(getInputByLabelText('The input').value).toBe('some different text'); 48 | 49 | getByText('Empty the input').click(); 50 | 51 | expect(getInputByLabelText('The input').value).toBe('X'); 52 | }); 53 | -------------------------------------------------------------------------------- /tests/react/hooks.test.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useLayoutEffect, useState } from 'react'; 2 | import { waitFor, act } from '@testing-library/react'; 3 | import { store as globalStore, WithStoreProp } from '../..'; 4 | import * as testUtils from '../testUtils'; 5 | 6 | it('should work with useState hook', () => { 7 | globalStore.counter = 0; 8 | 9 | const { getByText } = testUtils.collectAndRenderStrict( 10 | ({ store }: WithStoreProp) => { 11 | const [counter, setCounter] = useState(0); 12 | 13 | return ( 14 |
    15 |
    {`State count: ${counter}`}
    16 | 17 |
    {`Store count: ${store.counter}`}
    18 | 19 | 26 | 27 | 34 |
    35 | ); 36 | } 37 | ); 38 | 39 | getByText('State count: 0'); 40 | getByText('Store count: 0'); 41 | 42 | getByText('Increment state').click(); 43 | getByText('State count: 1'); 44 | getByText('Store count: 0'); 45 | 46 | getByText('Increment store').click(); 47 | getByText('State count: 1'); 48 | getByText('Store count: 1'); 49 | }); 50 | 51 | it('should work with useEffect hook', async () => { 52 | globalStore.counter = 0; 53 | const onMountMock = jest.fn(); 54 | const onCountChangeMock = jest.fn(); 55 | 56 | const { getByText } = testUtils.collectAndRenderStrict( 57 | ({ store }: WithStoreProp) => { 58 | useEffect(() => { 59 | onMountMock(); 60 | }, []); 61 | 62 | useEffect(() => { 63 | onCountChangeMock(); 64 | }, [store.counter]); 65 | 66 | return ( 67 |
    68 |
    {`Store count: ${store.counter}`}
    69 | 70 | 77 |
    78 | ); 79 | } 80 | ); 81 | 82 | expect(onMountMock).toHaveBeenCalledTimes(1); 83 | expect(onCountChangeMock).toHaveBeenCalledTimes(1); 84 | 85 | getByText('Store count: 0'); 86 | act(() => { 87 | getByText('Increment store').click(); 88 | }); 89 | getByText('Store count: 1'); 90 | 91 | await waitFor(() => {}); // useEffect is async 92 | 93 | expect(onCountChangeMock).toHaveBeenCalledTimes(2); 94 | expect(onMountMock).toHaveBeenCalledTimes(1); 95 | }); 96 | 97 | it('should work with useEffect hook on mount', async () => { 98 | globalStore.loaded = false; 99 | let renderCount = 0; 100 | 101 | const { getByText } = testUtils.collectAndRender( 102 | ({ store }: WithStoreProp) => { 103 | // Using useEffect like 'componentDidMount' 104 | useEffect(() => { 105 | store.loaded = true; 106 | }, []); 107 | 108 | renderCount++; 109 | return
    {store.loaded ? 'Loaded' : 'Loading...'}
    ; 110 | } 111 | ); 112 | 113 | // Interesting. React 'defers' the useEffect(), but it happens in the same 114 | // tick. So by the time this line runs, the component has already rendered 115 | // twice and store.loaded is true; 116 | getByText('Loaded'); 117 | expect(renderCount).toBe(2); 118 | }); 119 | 120 | it('changing store in useLayoutEffect should error', async () => { 121 | globalStore.loaded = false; 122 | 123 | testUtils.expectToLogError(() => { 124 | testUtils.collectAndRenderStrict(({ store }: WithStoreProp) => { 125 | useLayoutEffect(() => { 126 | // Oh no, useLayoutEffect is synchronous, so will fire during render 127 | store.loaded = true; 128 | }, []); 129 | 130 | return
    {store.loaded ? 'Loading...' : 'Loading'}
    ; 131 | }); 132 | }); 133 | }); 134 | -------------------------------------------------------------------------------- /tests/react/propTypes.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { initStore, PropTypes } from '../..'; 3 | import * as testUtils from '../testUtils'; 4 | 5 | it('should not listen to props read from prop types', () => { 6 | initStore({ 7 | prop1: 'This is prop1', 8 | prop2: 'This is prop2', 9 | prop3: 'This is prop3', 10 | }); 11 | 12 | const MyComponent = ({ store }: any) => ( 13 |
    14 |

    {store.prop1}

    15 |

    {store.prop2}

    16 |
    17 | ); 18 | 19 | MyComponent.propTypes = { 20 | store: PropTypes.shape({ 21 | prop1: PropTypes.string.isRequired, 22 | prop2: PropTypes.string.isRequired, 23 | prop3: PropTypes.string.isRequired, 24 | }).isRequired, 25 | }; 26 | 27 | const { getByText } = testUtils.collectAndRenderStrict(MyComponent); 28 | 29 | expect(testUtils.getAllListeners()).toEqual([ 30 | 'prop1', 31 | 'prop2', 32 | // Not prop3 33 | ]); 34 | 35 | getByText('This is prop1'); 36 | getByText('This is prop2'); 37 | }); 38 | 39 | /** 40 | * This test asserts that Recollect doesn't break prop type checking 41 | */ 42 | it('should warn for failed prop types', () => { 43 | initStore(); 44 | 45 | const MyComponent = ({ store }: any) => ( 46 |
    47 |

    {store.prop1}

    48 |
    49 | ); 50 | 51 | MyComponent.propTypes = { 52 | store: PropTypes.shape({ 53 | prop1: PropTypes.string.isRequired, 54 | }).isRequired, 55 | }; 56 | 57 | const consoleError = testUtils.expectToLogError(() => { 58 | testUtils.collectAndRenderStrict(MyComponent); 59 | }); 60 | 61 | expect(consoleError).toMatch( 62 | 'The prop `store.prop1` is marked as required in `MyComponent`, but its value is `undefined`' 63 | ); 64 | }); 65 | -------------------------------------------------------------------------------- /tests/testUtils.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { mocked } from 'ts-jest/utils'; 3 | import { render } from '@testing-library/react'; 4 | import { collect, internals } from '..'; 5 | import * as paths from '../src/shared/paths'; 6 | 7 | export const renderStrict = (children: React.ReactNode) => { 8 | return render({children}); 9 | }; 10 | 11 | // Use collectAndRenderStrict instead of this if you don't need to count 12 | // the number of renders 13 | export const collectAndRender = (Comp: React.ComponentType) => { 14 | const CollectedComp = collect(Comp); 15 | 16 | return render(); 17 | }; 18 | 19 | export const collectAndRenderStrict = (Comp: React.ComponentType) => { 20 | const CollectedComp = collect(Comp); 21 | 22 | return renderStrict(); 23 | }; 24 | 25 | export const propPathChanges = (handleChangeMock: jest.Mock) => 26 | handleChangeMock.mock.calls.map((call) => call[0].changedProps[0]); 27 | 28 | export const getAllListeners = () => 29 | Array.from(internals.listeners.keys()).map(paths.internalToUser); 30 | 31 | type ConsoleMethod = jest.FunctionPropertyNames>; 32 | type ConsoleMockFunc = { 33 | (func: () => void): string | string[]; 34 | }; 35 | 36 | /** 37 | * Run some code with the console mocked. 38 | */ 39 | const withMockedConsole = ( 40 | func: () => void, 41 | method: ConsoleMethod 42 | ): string | string[] => { 43 | jest.spyOn(console, method); 44 | const mockedConsole = mocked(window.console[method], true); 45 | mockedConsole.mockImplementation(() => {}); 46 | 47 | func(); 48 | 49 | const consoleOutput = 50 | mockedConsole.mock.calls.length === 1 51 | ? mockedConsole.mock.calls[0][0] 52 | : mockedConsole.mock.calls.map((args: [string]) => args[0]); 53 | mockedConsole.mockRestore(); 54 | 55 | return consoleOutput; 56 | }; 57 | 58 | export const withMockedConsoleInfo: ConsoleMockFunc = (func) => 59 | withMockedConsole(func, 'info'); 60 | 61 | export const withMockedConsoleWarn: ConsoleMockFunc = (func) => 62 | withMockedConsole(func, 'warn'); 63 | 64 | export const expectToLogError: ConsoleMockFunc = (func) => { 65 | const consoleError = withMockedConsole(func, 'error'); 66 | 67 | expect(consoleError).not.toBeUndefined(); 68 | 69 | return consoleError; 70 | }; 71 | 72 | export type TaskType = { 73 | id: number; 74 | name: string; 75 | done?: boolean; 76 | }; 77 | 78 | declare module '..' { 79 | // Add a few things used in the tests 80 | interface Store { 81 | tasks?: TaskType[]; 82 | // And anything else... 83 | [key: string]: any; 84 | } 85 | } 86 | 87 | // TODO: Delete me when this is merged: 88 | // https://github.com/DefinitelyTyped/DefinitelyTyped/pull/43102 89 | declare module '@testing-library/dom' { 90 | export function waitFor( 91 | callback: () => void, 92 | options?: { 93 | container?: HTMLElement; 94 | timeout?: number; 95 | interval?: number; 96 | mutationObserverOptions?: MutationObserverInit; 97 | } 98 | ): Promise; 99 | } 100 | -------------------------------------------------------------------------------- /tests/unit/batch.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import { collect, batch, store as globalStore, WithStoreProp } from '../..'; 4 | 5 | globalStore.count = 1; 6 | let parentCompRenderCount = 0; 7 | let comp1RenderCount = 0; 8 | let comp2RenderCount = 0; 9 | 10 | const Comp1 = collect(({ store }: WithStoreProp) => { 11 | comp1RenderCount++; 12 | 13 | return

    Comp1 count: {store.count}

    ; 14 | }); 15 | 16 | const Comp2 = collect(({ store }: WithStoreProp) => { 17 | comp2RenderCount++; 18 | 19 | return

    Comp2 count: {store.count}

    ; 20 | }); 21 | 22 | const ParentComponent = () => { 23 | parentCompRenderCount++; 24 | return ( 25 |
    26 | 27 | 28 |
    29 | ); 30 | }; 31 | 32 | it('should batch synchronous updates to the store', async () => { 33 | const { getByText } = render(); 34 | 35 | expect(parentCompRenderCount).toBe(1); 36 | expect(comp1RenderCount).toBe(1); 37 | expect(comp2RenderCount).toBe(1); 38 | expect(getByText(/Comp1 count:/)).toHaveTextContent('Comp1 count: 1'); 39 | expect(getByText(/Comp2 count:/)).toHaveTextContent('Comp2 count: 1'); 40 | 41 | globalStore.count++; 42 | 43 | expect(parentCompRenderCount).toBe(1); // shouldn't re-render 44 | expect(comp1RenderCount).toBe(2); 45 | expect(comp2RenderCount).toBe(2); 46 | expect(getByText(/Comp1 count:/)).toHaveTextContent('Comp1 count: 2'); 47 | expect(getByText(/Comp2 count:/)).toHaveTextContent('Comp2 count: 2'); 48 | 49 | globalStore.count++; 50 | globalStore.count++; 51 | 52 | expect(parentCompRenderCount).toBe(1); 53 | expect(comp1RenderCount).toBe(4); 54 | expect(comp2RenderCount).toBe(4); 55 | expect(getByText(/Comp1 count:/)).toHaveTextContent('Comp1 count: 4'); 56 | expect(getByText(/Comp2 count:/)).toHaveTextContent('Comp2 count: 4'); 57 | 58 | batch(() => { 59 | globalStore.count++; 60 | globalStore.count++; 61 | globalStore.count++; 62 | globalStore.count++; 63 | globalStore.count++; 64 | globalStore.count++; 65 | }); 66 | 67 | expect(parentCompRenderCount).toBe(1); 68 | 69 | // Of note: the render count is only 5 ... 70 | expect(comp1RenderCount).toBe(5); 71 | expect(comp2RenderCount).toBe(5); 72 | 73 | // But the count correctly shows 10 74 | expect(getByText(/Comp1 count:/)).toHaveTextContent('Comp1 count: 10'); 75 | expect(getByText(/Comp2 count:/)).toHaveTextContent('Comp2 count: 10'); 76 | }); 77 | -------------------------------------------------------------------------------- /tests/unit/debug.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { initStore, collect, WithStoreProp } from '../..'; 3 | import * as testUtils from '../testUtils'; 4 | 5 | it('should do the right thing', () => { 6 | initStore({ 7 | prop1: 'Prop 1', 8 | prop2: 'Prop 2', 9 | tasks: [ 10 | { 11 | id: 0, 12 | name: 'Task 0', 13 | }, 14 | { 15 | id: 1, 16 | name: 'Task 1', 17 | }, 18 | ], 19 | }); 20 | 21 | type TaskProps = WithStoreProp & { 22 | taskId: number; 23 | }; 24 | 25 | const Task = ({ store, taskId }: TaskProps) => { 26 | if (!store.tasks) return null; 27 | 28 | const task = store.tasks.find(({ id }) => id === taskId); 29 | 30 | if (!task) return null; 31 | 32 | return ( 33 |
    34 |

    {store.prop2}

    35 |

    {task.name}

    36 |
    37 | ); 38 | }; 39 | 40 | const TaskCollected = collect(Task); 41 | 42 | const TaskList = ({ store }: WithStoreProp) => ( 43 |
    44 |

    {store.prop1}

    45 | 46 | {store.tasks && 47 | store.tasks.map((task) => ( 48 | 49 | ))} 50 |
    51 | ); 52 | 53 | testUtils.collectAndRenderStrict(TaskList); 54 | 55 | expect(window.__RR__.getListenersByComponent()).toEqual({ 56 | Task: [ 57 | 'tasks', 58 | 'tasks.0', 59 | 'tasks.0.id', 60 | 'tasks.1', 61 | 'tasks.1.id', 62 | 'prop2', 63 | 'tasks.0.name', 64 | 'tasks.1.name', 65 | ], 66 | TaskList: [ 67 | 'prop1', 68 | 'tasks', 69 | 'tasks.0', 70 | 'tasks.0.id', 71 | 'tasks.1', 72 | 'tasks.1.id', 73 | ], 74 | }); 75 | 76 | // Filter for the task list only 77 | expect(window.__RR__.getListenersByComponent(/TaskList/)).toEqual({ 78 | TaskList: [ 79 | 'prop1', 80 | 'tasks', 81 | 'tasks.0', 82 | 'tasks.0.id', 83 | 'tasks.1', 84 | 'tasks.1.id', 85 | ], 86 | }); 87 | 88 | // Put the ID in the key and filter for a specific instance 89 | expect( 90 | window.__RR__.getListenersByComponent( 91 | 'Task0', 92 | (props: TaskProps) => props.taskId 93 | ) 94 | ).toEqual({ 95 | Task0: ['tasks', 'tasks.0', 'tasks.0.id', 'prop2', 'tasks.0.name'], 96 | }); 97 | 98 | expect(window.__RR__.getComponentsByListener()).toEqual({ 99 | tasks: ['TaskList', 'Task'], 100 | 'tasks.0': ['TaskList', 'Task'], 101 | 'tasks.0.id': ['TaskList', 'Task'], 102 | 'tasks.1': ['TaskList', 'Task'], 103 | 'tasks.1.id': ['TaskList', 'Task'], 104 | prop2: ['Task'], 105 | 'tasks.0.name': ['Task'], 106 | 'tasks.1.name': ['Task'], 107 | prop1: ['TaskList'], 108 | }); 109 | 110 | // Which components _instances_ listen to `tasks.0.id`? 111 | // Note that Task1 listens too because it loops over all tasks 112 | expect( 113 | window.__RR__.getComponentsByListener( 114 | 'tasks.0.id', 115 | (props: TaskProps) => props.taskId 116 | )['tasks.0.id'] 117 | ).toEqual(['TaskList', 'Task0', 'Task1']); 118 | 119 | expect( 120 | window.__RR__.getComponentsByListener( 121 | null, 122 | (props: TaskProps) => props.taskId 123 | ) 124 | ).toEqual({ 125 | tasks: ['TaskList', 'Task0', 'Task1'], 126 | 'tasks.0': ['TaskList', 'Task0', 'Task1'], 127 | 'tasks.0.id': ['TaskList', 'Task0', 'Task1'], 128 | 'tasks.1': ['TaskList', 'Task1'], 129 | 'tasks.1.id': ['TaskList', 'Task1'], 130 | prop2: ['Task0', 'Task1'], 131 | 'tasks.0.name': ['Task0'], 132 | 'tasks.1.name': ['Task1'], 133 | prop1: ['TaskList'], 134 | }); 135 | }); 136 | -------------------------------------------------------------------------------- /tests/unit/initStore.test.ts: -------------------------------------------------------------------------------- 1 | import { store, initStore } from '../..'; 2 | 3 | it('should replace the contents of the store', () => { 4 | store.propertyOne = 'the first property'; 5 | expect(store.propertyOne).toBe('the first property'); 6 | 7 | initStore({ 8 | propertyTwo: 'the second property', 9 | }); 10 | 11 | expect(store.propertyOne).toBeUndefined(); 12 | expect(store.propertyTwo).toBe('the second property'); 13 | 14 | expect(store).toEqual( 15 | expect.objectContaining({ 16 | propertyTwo: 'the second property', 17 | }) 18 | ); 19 | }); 20 | -------------------------------------------------------------------------------- /tests/unit/noWastedUpdates.test.ts: -------------------------------------------------------------------------------- 1 | import { store, afterChange } from '../..'; 2 | 3 | const handleChange = jest.fn(); 4 | afterChange(handleChange); 5 | 6 | afterEach(() => { 7 | handleChange.mockClear(); 8 | }); 9 | 10 | it('should only update the items that change', () => { 11 | store.tasks = [ 12 | { id: 0, name: 'task 0', done: true }, 13 | { id: 1, name: 'task 1', done: false }, 14 | { id: 2, name: 'task 2', done: true }, 15 | { id: 3, name: 'task 3', done: false }, 16 | ]; 17 | 18 | handleChange.mockClear(); 19 | 20 | // Simulate marking all tasks as done 21 | store.tasks.forEach((task) => { 22 | // eslint-disable-next-line no-param-reassign 23 | task.done = true; 24 | }); 25 | 26 | // But two tasks were already done so shouldn't trigger an update 27 | // So only two should actually trigger an update 28 | expect(handleChange).toHaveBeenCalledTimes(2); 29 | 30 | const changeEvent1 = handleChange.mock.calls[0][0]; 31 | const changeEvent2 = handleChange.mock.calls[1][0]; 32 | 33 | expect(changeEvent1.changedProps).toEqual(['tasks.1.done']); 34 | expect(changeEvent2.changedProps).toEqual(['tasks.3.done']); 35 | }); 36 | 37 | it('should ignore adding undefined', () => { 38 | store.newUndefined = undefined; 39 | 40 | expect(handleChange).toHaveBeenCalledTimes(0); 41 | }); 42 | -------------------------------------------------------------------------------- /tests/unit/nonReactStatics.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { collect, WithStoreProp } from '../..'; 3 | 4 | describe('should copy static methods to the collected component', () => { 5 | it('for a class component', () => { 6 | class ClassWithStaticRaw extends React.Component { 7 | static returnDogs: () => string; 8 | 9 | static returnCats() { 10 | return 'cats'; 11 | } 12 | 13 | render() { 14 | return

    Hi

    ; 15 | } 16 | } 17 | 18 | ClassWithStaticRaw.returnDogs = () => 'dogs'; 19 | 20 | const ClassWithStatic = collect(ClassWithStaticRaw); 21 | 22 | expect(ClassWithStatic.returnCats()).toBe('cats'); 23 | expect(ClassWithStatic.returnDogs()).toBe('dogs'); 24 | }); 25 | 26 | it('for a function component', () => { 27 | const FuncWithStaticRaw = () =>

    Hi

    ; 28 | 29 | FuncWithStaticRaw.returnDogs = () => 'dogs'; 30 | 31 | const ClassWithStatic = collect(FuncWithStaticRaw); 32 | 33 | expect(ClassWithStatic.returnDogs()).toBe('dogs'); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /tests/unit/propTypes.test.tsx: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | import React from 'react'; 3 | import { PropTypes, initStore } from '../..'; 4 | import * as testUtils from '../testUtils'; 5 | 6 | it('should identify prop type mismatch for function components', () => { 7 | const MyComponent = (props) => { 8 | return

    {props.prop1}

    ; 9 | }; 10 | 11 | MyComponent.propTypes = { 12 | prop1: PropTypes.number.isRequired, 13 | }; 14 | 15 | const errorMessage = testUtils.expectToLogError(() => { 16 | testUtils.renderStrict(); 17 | }); 18 | 19 | expect(errorMessage).toMatch( 20 | 'Warning: Failed prop type: Invalid prop `prop1` of type `string` supplied to `MyComponent`, expected `number`.' 21 | ); 22 | }); 23 | 24 | it('should identify missing prop type for function components', () => { 25 | const MyFunctionComponent = (props) => { 26 | return

    {props.prop1}

    ; 27 | }; 28 | 29 | MyFunctionComponent.propTypes = { 30 | prop1: PropTypes.string.isRequired, 31 | // eslint-disable-next-line react/no-unused-prop-types 32 | prop2: PropTypes.number.isRequired, 33 | }; 34 | 35 | const errorMessage = testUtils.expectToLogError(() => { 36 | testUtils.renderStrict(); 37 | }); 38 | 39 | expect(errorMessage).toMatch( 40 | 'Warning: Failed prop type: The prop `prop2` is marked as required in `MyFunctionComponent`, but its value is `undefined`' 41 | ); 42 | }); 43 | 44 | it('should identify prop type errors for classes', () => { 45 | // eslint-disable-next-line react/prefer-stateless-function 46 | class MyClassComponent extends React.Component { 47 | static propTypes = { 48 | prop1: PropTypes.number.isRequired, 49 | }; 50 | 51 | render() { 52 | return

    {this.props.prop1}

    ; 53 | } 54 | } 55 | 56 | const errorMessage = testUtils.expectToLogError(() => { 57 | const { getByText } = testUtils.renderStrict( 58 | 59 | ); 60 | 61 | // This should log an error, but still render correctly. 62 | getByText('a string'); 63 | }); 64 | 65 | expect(errorMessage).toMatch( 66 | 'Warning: Failed prop type: Invalid prop `prop1` of type `string` supplied to `MyClassComponent`, expected `number`.' 67 | ); 68 | }); 69 | 70 | it('should pass all valid prop types', () => { 71 | // This is really testing the implementation of the `prop-types` library, 72 | // but since it's quite conceivable that wrapping it in a proxy could break 73 | // it, we do it anyway. 74 | const MyComponent = (props) => { 75 | return

    {props.store.readByChild}

    ; 76 | }; 77 | 78 | const MyOtherComponent = () =>

    Foo

    ; 79 | 80 | const MyCollectedComponent = (props) => { 81 | return ( 82 |
    83 |

    {props.store.readByParent}

    84 | 85 |
    86 | ); 87 | }; 88 | 89 | initStore({ 90 | readByParent: 'Read By Parent', 91 | readByChild: 'Read By Child', 92 | testAny: 'Any', 93 | testArray: ['array'], 94 | testBool: true, 95 | testFunc: () => {}, 96 | testNumber: 77, 97 | testObject: { foo: 'bar' }, 98 | testString: 'string', 99 | testNode:

    Hello

    , 100 | testElement:

    Hello

    , 101 | testSymbol: Symbol('foo'), 102 | testElementType: MyOtherComponent, 103 | testInstanceOfMap: new Map([['foo', 'bar']]), 104 | testOneOf: 'foo', 105 | testOneOfType: ['an array'], 106 | testArrayOf: [1, 2, 3], 107 | testObjectOf: { foo: 1234 }, 108 | testShape: { 109 | fooShape: 'bar', 110 | bazShape: 'Luhrmann', 111 | extra: 'fine', 112 | }, 113 | testExact: { 114 | fooExact: 'bar', 115 | bazExact: 'Luhrmann', 116 | }, 117 | }); 118 | 119 | /* eslint-disable */ 120 | MyComponent.propTypes = { 121 | store: PropTypes.shape({ 122 | readByParent: PropTypes.string.isRequired, 123 | readByChild: PropTypes.string.isRequired, 124 | 125 | // One for each prop type 126 | testAny: PropTypes.any.isRequired, 127 | testAny: PropTypes.any.isRequired, 128 | testArray: PropTypes.array.isRequired, 129 | testBool: PropTypes.bool.isRequired, 130 | testFunc: PropTypes.func.isRequired, 131 | testNumber: PropTypes.number.isRequired, 132 | testObject: PropTypes.object.isRequired, 133 | testString: PropTypes.string.isRequired, 134 | testNode: PropTypes.node.isRequired, 135 | testElement: PropTypes.element.isRequired, 136 | testSymbol: PropTypes.symbol.isRequired, 137 | testElementType: PropTypes.elementType.isRequired, 138 | testInstanceOfMap: PropTypes.instanceOf(Map).isRequired, 139 | testOneOf: PropTypes.oneOf(['foo']).isRequired, 140 | testOneOfType: PropTypes.oneOfType([PropTypes.array]).isRequired, 141 | testArrayOf: PropTypes.arrayOf(PropTypes.number).isRequired, 142 | testObjectOf: PropTypes.objectOf(PropTypes.number).isRequired, 143 | testShape: PropTypes.shape({ 144 | fooShape: PropTypes.string.isRequired, 145 | bazShape: PropTypes.string.isRequired, 146 | }).isRequired, 147 | testExact: PropTypes.exact({ 148 | fooExact: PropTypes.string.isRequired, 149 | bazExact: PropTypes.string.isRequired, 150 | }).isRequired, 151 | }), 152 | }; 153 | /* eslint-enable */ 154 | 155 | const { getByText } = testUtils.collectAndRenderStrict(MyCollectedComponent); 156 | getByText('Read By Parent'); 157 | getByText('Read By Child'); 158 | 159 | expect(testUtils.getAllListeners()).toEqual(['readByParent', 'readByChild']); 160 | }); 161 | -------------------------------------------------------------------------------- /tests/unit/useProps.test.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { 3 | initStore, 4 | store as globalStore, 5 | useProps, 6 | WithStoreProp, 7 | } from '../..'; 8 | import * as testUtils from '../testUtils'; 9 | 10 | beforeEach(() => { 11 | initStore({ 12 | prop1: 'This is prop1', 13 | prop2: 'This is prop2', 14 | }); 15 | }); 16 | 17 | it('should listen to props', () => { 18 | testUtils.collectAndRender(({ store }: any) => { 19 | useProps([store.prop1]); 20 | 21 | return

    {store.prop2}

    ; 22 | }); 23 | 24 | expect(testUtils.getAllListeners()).toEqual(['prop1', 'prop2']); 25 | }); 26 | 27 | it('should listen to object children', () => { 28 | globalStore.arr = [{ name: 'Task one' }, { name: 'Task two' }]; 29 | globalStore.obj = { 30 | one: 'one!', 31 | two: 'two!', 32 | }; 33 | 34 | testUtils.collectAndRender(({ store }: any) => { 35 | useProps([store.prop1, ...store.arr]); 36 | 37 | // A terrible idea, but works 38 | useProps(Object.values(store.obj)); 39 | 40 | return

    {store.prop2}

    ; 41 | }); 42 | 43 | expect(testUtils.getAllListeners()).toEqual([ 44 | 'prop1', 45 | 'arr', 46 | 'arr.0', 47 | 'arr.1', 48 | 'obj', 49 | 'obj.one', 50 | 'obj.two', 51 | 'prop2', 52 | ]); 53 | }); 54 | 55 | it('should work inline', () => { 56 | const { container } = testUtils.collectAndRender(({ store }: any) => ( 57 |
    58 | {useProps([store.prop1])} 59 | 60 |

    {store.prop2}

    61 |
    62 | )); 63 | 64 | // useProps doesn't render anything 65 | expect(container.innerHTML).toBe('

    This is prop2

    '); 66 | 67 | expect(testUtils.getAllListeners()).toEqual(['prop1', 'prop2']); 68 | }); 69 | 70 | it('should handle duplicate props', () => { 71 | globalStore.arr = [ 72 | { 73 | name: 'David', 74 | age: 75, 75 | }, 76 | ]; 77 | globalStore.loading = false; 78 | 79 | testUtils.collectAndRender(({ store }: any) => { 80 | useProps([ 81 | store, // Redundant 82 | store.prop1, 83 | store.prop2, // Redundant (used during render) 84 | store.arr[0].name, 85 | store.arr, // Redundant (inferred from store.arr[0].name) 86 | store.loaded, // Redundant (used during render) 87 | ]); 88 | 89 | return ( 90 |
    91 | {!store.loaded &&

    Loading...

    } 92 | 93 |

    {store.prop2}

    94 |
    95 | ); 96 | }); 97 | 98 | expect(testUtils.getAllListeners()).toEqual([ 99 | 'prop1', 100 | 'prop2', 101 | 'arr', 102 | 'arr.0', 103 | 'arr.0.name', 104 | 'loaded', 105 | ]); 106 | }); 107 | 108 | it('should ignore non-store props', () => { 109 | testUtils.collectAndRender(({ store }: any) => { 110 | const listOfAnimals = ['cats', 'dogs', 'Sid Vicious']; 111 | 112 | useProps([store.prop1, listOfAnimals]); 113 | 114 | return

    {store.prop2}

    ; 115 | }); 116 | 117 | expect(testUtils.getAllListeners()).toEqual(['prop1', 'prop2']); 118 | }); 119 | 120 | it('can be used multiple times', () => { 121 | testUtils.collectAndRender(({ store }: any) => { 122 | useProps([store.prop1]); 123 | useProps([store.prop3]); 124 | useProps([store.prop4]); 125 | 126 | return

    {store.prop2}

    ; 127 | }); 128 | 129 | expect(testUtils.getAllListeners()).toEqual([ 130 | 'prop1', 131 | 'prop3', 132 | 'prop4', // doesn't exist, doesn't matter 133 | 'prop2', 134 | ]); 135 | }); 136 | 137 | it('can be read in lifecycle methods', () => { 138 | // These work, but aren't a good idea. Maybe one day this will be a warning. 139 | testUtils.collectAndRender( 140 | class MyComponent extends React.Component { 141 | state = {}; 142 | 143 | componentDidMount() { 144 | useProps([this.props.store.componentDidMountProp]); 145 | } 146 | 147 | static getDerivedStateFromProps(props: Readonly) { 148 | useProps([props.store.getDerivedStateFromPropsProp]); 149 | return null; 150 | } 151 | 152 | render() { 153 | const { store } = this.props; 154 | useProps([store.prop1]); 155 | 156 | return

    {store.prop2}

    ; 157 | } 158 | } 159 | ); 160 | 161 | expect(testUtils.getAllListeners()).toEqual([ 162 | 'getDerivedStateFromPropsProp', 163 | 'prop1', 164 | 'prop2', 165 | 'componentDidMountProp', 166 | ]); 167 | }); 168 | 169 | it('should work with changing state', () => { 170 | type Props = WithStoreProp & { 171 | hiddenMessage: string; 172 | }; 173 | 174 | const { queryByText, getByText } = testUtils.collectAndRenderStrict( 175 | ({ store }: Props) => { 176 | const [showHiddenMessage, setShowHiddenMessage] = useState(false); 177 | 178 | useProps([store.hiddenMessage]); 179 | 180 | return ( 181 |
    182 | {showHiddenMessage &&

    {store.hiddenMessage}

    } 183 | 184 | 191 |
    192 | ); 193 | } 194 | ); 195 | 196 | expect(testUtils.getAllListeners()).toEqual(['hiddenMessage']); 197 | 198 | expect(queryByText('Hidden message')).not.toBeInTheDocument(); 199 | 200 | globalStore.hiddenMessage = 'Hidden message'; 201 | getByText('Show hidden message').click(); 202 | 203 | expect(queryByText('Hidden message')).toBeInTheDocument(); 204 | 205 | globalStore.hiddenMessage = 'A new message!'; 206 | 207 | expect(queryByText('A new message!')).toBeInTheDocument(); 208 | }); 209 | -------------------------------------------------------------------------------- /tests/unit/utils.test.ts: -------------------------------------------------------------------------------- 1 | import * as utils from '../../src/shared/utils'; 2 | 3 | it('should update deep', () => { 4 | const data = { 5 | level1: [ 6 | { 7 | name: 'Task one', 8 | data: { done: true, date: 1234 }, 9 | }, 10 | { 11 | name: 'Task two', 12 | data: { done: true, date: 5678 }, 13 | }, 14 | ], 15 | level2: { 16 | deep: { 17 | data: 'the result', 18 | }, 19 | }, 20 | }; 21 | 22 | const paths: any[] = []; 23 | 24 | utils.updateDeep(data, (item: any, path: any[]) => { 25 | paths.push(path); 26 | }); 27 | 28 | expect(paths).toEqual([ 29 | [], 30 | ['level1'], 31 | ['level1', 0], 32 | ['level1', 0, 'name'], 33 | ['level1', 0, 'data'], 34 | ['level1', 0, 'data', 'done'], 35 | ['level1', 0, 'data', 'date'], 36 | ['level1', 1], 37 | ['level1', 1, 'name'], 38 | ['level1', 1, 'data'], 39 | ['level1', 1, 'data', 'done'], 40 | ['level1', 1, 'data', 'date'], 41 | ['level2'], 42 | ['level2', 'deep'], 43 | ['level2', 'deep', 'data'], 44 | ]); 45 | }); 46 | 47 | it('should create a full clone', () => { 48 | const data = { 49 | level1: [ 50 | { 51 | name: 'Task one', 52 | data: { done: true, date: 1234 }, 53 | }, 54 | { 55 | name: 'Task two', 56 | data: { done: true, date: 5678 }, 57 | }, 58 | ], 59 | level2: { 60 | deep: { 61 | data: 'the result', 62 | }, 63 | }, 64 | }; 65 | 66 | const clone = utils.updateDeep(data, utils.clone); 67 | 68 | expect(clone).toEqual(data); 69 | expect(clone).not.toBe(data); 70 | expect(clone.level1).not.toBe(data.level1); 71 | expect(clone.level1[0]).not.toBe(data.level1[0]); 72 | expect(clone.level1[0].data).not.toBe(data.level1[0].data); 73 | }); 74 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "incremental": true, 5 | "jsx": "react", 6 | "module": "CommonJS", 7 | "skipLibCheck": true, 8 | "strict": true, 9 | "target": "ES2017" 10 | }, 11 | "exclude": ["demo", "dist", "index.*.js"] 12 | } 13 | --------------------------------------------------------------------------------