├── .babelrc ├── .dockerignore ├── .envTemplate ├── .eslintignore ├── .eslintrc ├── .gitattributes ├── .github └── workflows │ ├── build.yml │ └── deploy.yml ├── .gitignore ├── .prettierrc.js ├── CODE_OF_CONDUCT.md ├── Dockerfile ├── LICENSE.md ├── README.md ├── docker-compose.yml ├── package-lock.json ├── package.json ├── packageProdOverrides-react.json ├── packageProdOverrides-solid.json ├── src ├── common │ └── index.js ├── react.js ├── react │ ├── PageProvider.jsx │ └── appContext.js ├── solid.js └── solid │ ├── PageProvider.jsx │ └── appContext.js ├── test ├── env │ ├── App.jsx │ ├── ExamplePartial.jsx │ ├── Layout.jsx │ ├── actions │ │ ├── dataSuppliers │ │ │ ├── dataSuppliers.js │ │ │ ├── feedWithIncomingData │ │ │ │ └── feedWithIncomingData.js │ │ │ ├── getThat │ │ │ │ └── getThat.js │ │ │ ├── getThis │ │ │ │ └── getThis.js │ │ │ ├── squash1 │ │ │ │ └── squash1.js │ │ │ ├── squash2 │ │ │ │ └── squash2.js │ │ │ └── squash3 │ │ │ │ └── squash3.js │ │ ├── reloadTypes.js │ │ └── userActions │ │ │ ├── doThat │ │ │ └── doThat.js │ │ │ ├── doThis │ │ │ └── doThis.js │ │ │ ├── triggerSquashedDataSuppliers │ │ │ └── triggerSquashedDataSuppliers.js │ │ │ └── userActions.js │ ├── react │ │ ├── .babelrc │ │ ├── babel.config.js │ │ ├── index.js │ │ ├── package.json │ │ └── yarn.lock │ ├── solid │ │ ├── babel.config.js │ │ ├── index.js │ │ ├── package.json │ │ └── yarn.lock │ ├── testHelpers │ │ ├── appChangeHistorySnapshotTypes.json │ │ ├── appPort.js │ │ ├── elementClassNames.json │ │ ├── handleAppChangeHistoryRequest.js │ │ └── registerMutationObserver.js │ └── webpack.config.js ├── features │ └── appStateManipulation.js └── runner │ └── index.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | npm-debug.log 3 | Dockerfile 4 | -------------------------------------------------------------------------------- /.envTemplate: -------------------------------------------------------------------------------- 1 | NODE_ENV=local 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | test/env/react/node_modules 3 | test/env/solid/node_modules 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "standard", 4 | "standard-jsx", 5 | "standard-react", 6 | "plugin:react-hooks/recommended" 7 | ], 8 | "parserOptions": { 9 | "ecmaVersion": 2020 10 | }, 11 | "env": { 12 | "browser": true, 13 | "es6": true, 14 | "mocha": true 15 | }, 16 | "plugins": [ 17 | "react-hooks" 18 | ], 19 | "rules": { 20 | "indent": "off", 21 | "jsx-quotes": [ 22 | "error", 23 | "prefer-double" 24 | ], 25 | "multiline-ternary": "off", 26 | "space-before-function-paren": "off", 27 | "react/prop-types": "off", 28 | "react/react-in-jsx-scope": "off" 29 | }, 30 | "settings": { 31 | "react": { 32 | "version": "18.0.0" 33 | } 34 | } 35 | } -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | .gitattributes 2 | ================================================= 3 | # These settings are for any web project 4 | 5 | # Handle line endings automatically for files detected as text 6 | # and leave all files detected as binary untouched. 7 | * text=auto 8 | 9 | # Force the following filetypes to have unix eols, so Windows does not break them 10 | *.* text eol=lf 11 | 12 | # Windows forced line-endings 13 | /.idea/* text eol=crlf 14 | 15 | # 16 | ## These files are binary and should be left untouched 17 | # 18 | 19 | # (binary is a macro for -text -diff) 20 | *.png binary 21 | *.jpg binary 22 | *.jpeg binary 23 | *.gif binary 24 | *.ico binary 25 | *.mov binary 26 | *.mp4 binary 27 | *.mp3 binary 28 | *.flv binary 29 | *.fla binary 30 | *.swf binary 31 | *.gz binary 32 | *.zip binary 33 | *.7z binary 34 | *.ttf binary 35 | *.eot binary 36 | *.woff binary 37 | *.pyc binary 38 | *.pdf binary 39 | *.ez binary 40 | *.bz2 binary 41 | *.swp binary 42 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build Storecle 2 | on: 3 | pull_request: 4 | branches: [master] 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - uses: actions/setup-node@v2 11 | with: 12 | node-version: '14.x' 13 | registry-url: 'https://registry.npmjs.org' 14 | scope: '@gluecodes' 15 | - run: yarn install --production=false 16 | - run: yarn lint 17 | - run: | 18 | sudo apt-get update 19 | sudo apt-get install -y wget gnupg ca-certificates 20 | sudo wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | sudo apt-key add - 21 | sudo sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' 22 | sudo apt-get update 23 | sudo apt-get install libxss1 24 | sudo apt-get install -y google-chrome-stable 25 | sudo rm -rf /var/lib/apt/lists/* 26 | sudo wget --quiet https://raw.githubusercontent.com/vishnubob/wait-for-it/master/wait-for-it.sh -O /usr/sbin/wait-for-it.sh 27 | sudo chmod +x /usr/sbin/wait-for-it.sh 28 | - run: yarn start & sleep 5 && yarn test 29 | working-directory: ./test/env/react 30 | - run: yarn start & sleep 5 && yarn test 31 | working-directory: ./test/env/solid 32 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Storecle 2 | on: 3 | push: 4 | branches: [master] 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - uses: actions/setup-node@v2 11 | with: 12 | node-version: '14.x' 13 | registry-url: 'https://registry.npmjs.org' 14 | scope: '@gluecodes' 15 | - run: yarn install --production=false 16 | - run: yarn lint 17 | - run: | 18 | sudo apt-get update 19 | sudo apt-get install -y wget gnupg ca-certificates 20 | sudo wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | sudo apt-key add - 21 | sudo sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' 22 | sudo apt-get update 23 | sudo apt-get install libxss1 24 | sudo apt-get install -y google-chrome-stable 25 | sudo rm -rf /var/lib/apt/lists/* 26 | sudo wget --quiet https://raw.githubusercontent.com/vishnubob/wait-for-it/master/wait-for-it.sh -O /usr/sbin/wait-for-it.sh 27 | sudo chmod +x /usr/sbin/wait-for-it.sh 28 | - run: | 29 | sudo apt-get update 30 | sudo apt-get install -y jq 31 | - run: yarn start & sleep 5 && yarn test 32 | working-directory: ./test/env/react 33 | - run: yarn start & sleep 5 && yarn test 34 | working-directory: ./test/env/solid 35 | - run: echo $(jq -s '.[0] * .[1]' package.json packageProdOverrides-react.json) > package.json 36 | - run: yarn publish --non-interactive 37 | env: 38 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 39 | - run: echo $(jq -s '.[0] * .[1]' package.json packageProdOverrides-solid.json) > package.json 40 | - run: yarn publish --non-interactive 41 | env: 42 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | ################################################ 61 | # Miscellaneous 62 | # 63 | # Common files generated by text editors, 64 | # operating systems, file systems, etc. 65 | ################################################ 66 | 67 | *~ 68 | *# 69 | .DS_STORE 70 | .netbeans 71 | nbproject 72 | .idea 73 | .node_history 74 | 75 | dist/ 76 | test/env/react/dist 77 | test/env/solid/dist 78 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: false, 3 | printWidth: 120, 4 | singleQuote: true, 5 | tabWidth: 2, 6 | trailingComma: 'none' 7 | } 8 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at hello@glue.codes. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:16 2 | 3 | RUN apt-get update \ 4 | && apt-get install -y wget gnupg ca-certificates \ 5 | && wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - \ 6 | && sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' \ 7 | && apt-get update \ 8 | && apt-get install libxss1 \ 9 | && apt-get install -y google-chrome-stable \ 10 | && rm -rf /var/lib/apt/lists/* \ 11 | && wget --quiet https://raw.githubusercontent.com/vishnubob/wait-for-it/master/wait-for-it.sh -O /usr/sbin/wait-for-it.sh \ 12 | && chmod +x /usr/sbin/wait-for-it.sh 13 | 14 | WORKDIR /src/storecle 15 | ADD . /src/storecle 16 | RUN yarn install 17 | 18 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021 Krzysztof Czopp 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Storecle 2 | 3 | **@gluecodes/storecle** 4 | 5 | [![NPM Version](https://img.shields.io/npm/v/@gluecodes/storecle-solid.svg?style=flat)](https://www.npmjs.com/package/@gluecodes/storecle-solid) 6 | 7 | A neat uni-directional app state management for [React](https://reactjs.org/) and [Solid](https://www.solidjs.com/) (:heart:). 8 | 9 | ## Features 10 | 11 | Storecle uses a simple mental model which lets you access app-wide actions and their results by using Context API. 12 | It consists of 4 main building blocks i.e. Store, User Actions (actions triggered by a user), Data Suppliers (actions executed prior to rendering) and Reload Types (action re-trigger groups). 13 | The actions are just functions which are implicitly bound to the Store and write their results by returning/resolving. 14 | Then, their results are accessible by their own names. 15 | 16 | To improve the code re-usability, Data Suppliers use a middleware pattern. They are executed in the order you specify and pass a snapshot of Store from one to another, letting you split the logic into small, specified functions. 17 | 18 | - It works with both [React](https://reactjs.org/) and [Solid](https://www.solidjs.com/) (it's framework agnostic to certain degree). 19 | - It uses Context API and `useEffect` / `createEffect` to provide action re-triggers based on specified Store changes. 20 | - It facilitates splitting the business logic into granual, re-usable functions by applying a middleware pattern. 21 | - It simplifies naming and reduces noise by letting you access action results by their own names. 22 | - It provides an elegant approach to actions feeding UI with incoming data (e.g. from Web Sockets). 23 | - It is made to work with your IDE's code auto-completion. 24 | 25 | ## Motivation 26 | 27 | I :heart: Redux, but it leaves plenty of room to be misused. Hence, Storecle is my proposal to let developers rely less on self-discipline and more on tooling and self-restrictive design. 28 | 29 | 1. To provide an easy way of separating app-wide logic from views i.e.: 30 | - No inline: data fetches, transformers, conditionals. 31 | - No nested action dispatchers upon other action completion. 32 | 2. To facilitate the action re-usability and modularization. 33 | 3. To provide a gradual path for [React](https://reactjs.org/) developers willing to use [Solid](https://www.solidjs.com/). 34 | 35 | ## Installation 36 | 37 | React: 38 | 39 | ```bash 40 | yarn add @gluecodes/storecle-react 41 | ``` 42 | 43 | or 44 | 45 | ```bash 46 | npm i @gluecodes/storecle-react 47 | ``` 48 | 49 | Solid: 50 | 51 | ```bash 52 | yarn add @gluecodes/storecle-solid 53 | ``` 54 | 55 | or 56 | 57 | ```bash 58 | npm i @gluecodes/storecle-solid 59 | ``` 60 | 61 | It works along with either [React](https://reactjs.org/) or [Solid](https://www.solidjs.com/) that also needs to be installed in your app. For details, see their own documentations. 62 | 63 | ## Usage 64 | 65 | This module exports 3 constructs that can be imported for a particular framework in different parts of your app. 66 | 67 | ```javascript 68 | import { builtInActions, PageProvider, useAppContext } from '@gluecodes/storecle-react' 69 | ``` 70 | 71 | or 72 | 73 | ```javascript 74 | import { builtInActions, PageProvider, useAppContext } from '@gluecodes/storecle-solid' 75 | ``` 76 | 77 | For the purpose of the example I used a Solid version. 78 | 79 | Soon the official starter templates will be released. Using this library means following certain patterns which are explained below using a simple counter example. 80 | 81 | ### Mental Model 82 | 83 | > See: [Code Sandbox](https://codesandbox.io/s/bold-carlos-tj18g?file=/src/App.js) example for React. 84 | 85 | > See: [Code Sandbox](https://codesandbox.io/s/awesome-hertz-jdcgg?file=/src/App.jsx) example for Solid. 86 | 87 | File tree: 88 | 89 | ``` 90 | . 91 | ├── actions 92 | │   ├── dataSuppliers (#2) 93 | │   │   └── dataSuppliers.js 94 | │   ├── reloadTypes.js (#4) 95 | │   └── userActions (#3) 96 | │   └── userActions.js 97 | ├── index.jsx (#1) 98 | ├── Layout.jsx (#5) 99 | └── partials (#6) 100 | └── Counter 101 | └── Counter.jsx 102 | ``` 103 | 104 | #### 1. Page Container 105 | 106 | Page provider wraps a given Layout around a single app context. 107 | 108 | - `dataSupplierPipeline` - an array providing the order in which Data Suppliers are executed. 109 | - `dataSuppliers` - an object containing Data Suppliers. 110 | - `getLayout` - a function which returns the page Layout. 111 | - `reloadTypes` - an object containing Reload Types. 112 | - `userActions` - an object containing User Actions. 113 | - `onError` - a function triggered when an error is thrown either in Data Suppliers or User Actions. 114 | 115 | `./index.jsx` 116 | 117 | ```javascript 118 | import { PageProvider } from '@gluecodes/storecle-solid' 119 | 120 | import * as dataSuppliers from './actions/dataSuppliers/dataSuppliers' 121 | import * as userActions from './actions/userActions/userActions' 122 | import * as reloadTypes from './actions/reloadTypes' 123 | 124 | import Layout from './Layout.jsx' 125 | 126 | export default () => ( 127 | Layout} 131 | reloadTypes={reloadTypes} 132 | userActions={userActions} 133 | onError={(err) => { 134 | console.error(err) 135 | }} 136 | /> 137 | ) 138 | ``` 139 | 140 | #### 2. Data Suppliers 141 | 142 | Data suppliers provide data prior to rendering. Note the early returns which demonstrate how to resolve cached data based on Reload Type. 143 | 144 | - `builtInActions` - an object containing the following built-in User Actions: 145 | - `onStoreChanged` - a function which receives a callback to be triggered when Store changes. 146 | - `runUserActions` - a function which allows for executing multiple User Actions at once. 147 | - `runDataSuppliers` - a function which receives a Reload Type name. Note that it's exposed to ease the integration with legacy apps. Don't call it manually as Data Suppliers are implicitly reloaded based on the provided Reload Types. 148 | - Each Data Supplier passes two arguments: `resultOf` and `nameOf`. 149 | - `resultOf` - a function providing a result of a given Data Supplier or User Action. 150 | - `nameOf` - a function providing a name of either Data Supplier, User Action or Reload Type. 151 | - Data Suppliers can be either sync or async and write to a central Store by returning/resolving. 152 | 153 | `./actions/dataSuppliers/dataSuppliers.js` 154 | 155 | ```javascript 156 | import { builtInActions } from '@gluecodes/storecle-solid' 157 | import { reFetchCounter } from '../reloadTypes' 158 | 159 | export function getCounter(resultOf, nameOf) { 160 | const reloadType = resultOf(builtInActions.runDataSuppliers) 161 | const shouldFetch = reloadType === 'full' || reloadType === nameOf(reFetchCounter) 162 | 163 | if (!shouldFetch) { 164 | return resultOf(getCounter) 165 | } 166 | 167 | return global.sessionStorage.getItem('appWideCounter') || 0 168 | } 169 | 170 | export function getTexts(resultOf) { 171 | if (resultOf(builtInActions.runDataSuppliers) !== 'full') { 172 | return resultOf(getTexts) 173 | } 174 | 175 | return { 176 | Click: 'Click' 177 | } 178 | } 179 | ``` 180 | 181 | #### 3. User Actions 182 | 183 | Actions triggered by a user. 184 | 185 | `./actions/userActions/userActions.js` 186 | 187 | ```javascript 188 | export function incrementCounter(counter) { 189 | const incrementedCounter = Number(counter) + 1 190 | 191 | global.sessionStorage.setItem('appWideCounter', incrementedCounter) 192 | } 193 | ``` 194 | 195 | #### 4. Reload Types 196 | 197 | A way to tell the app to re-run Data Suppliers based on executed User Actions. 198 | 199 | - A Reload Type groups User Actions together to tell the app to reload all Data Suppliers as a consequence of their execution. 200 | - When any of its User Actions is triggered, the app sets the Reload Type name under built-in `runDataSuppliers` and reloads all Data Suppliers. 201 | - Data Suppliers can benefit from caching by early returning their results based on Reload Type name. 202 | - Each Reload Type is a function which passes `nameOf` and returns an array of User Action names. 203 | - `nameOf` - a function providing a name of User Action. 204 | 205 | `./actions/reloadTypes.js` 206 | 207 | ```javascript 208 | import { incrementCounter } from './userActions/userActions' 209 | 210 | export const reFetchCounter = (nameOf) => [nameOf(incrementCounter)] 211 | ``` 212 | 213 | #### 5. Layout 214 | 215 | Nothing else than the page layout. 216 | 217 | `./Layout.jsx` 218 | 219 | ```jsx 220 | import Counter from './partials/Counter/Counter.jsx' 221 | 222 | export default () => ( 223 | <> 224 | 225 | 226 | ) 227 | ``` 228 | 229 | #### 6. Partials 230 | 231 | Partials are self-contained pieces of UI which have access to app state via the app context. 232 | 233 | - `useAppContext` - a function which returns an array of 3 items: `resultOf`, `action`, `nameOf`. 234 | - `resultOf` - a function providing a result of a given Data Supplier or User Action. 235 | - `action` - a function which triggers User Action. 236 | - `nameOf` - a function providing a name of either Data Supplier or User Action. 237 | 238 | `./partials/Counter/Counter.jsx` 239 | 240 | ```jsx 241 | import { useAppContext } from '@gluecodes/storecle-solid' 242 | 243 | import { getCounter, getTexts } from '../../actions/dataSuppliers/dataSuppliers' 244 | import { incrementCounter } from '../../actions/userActions/userActions' 245 | 246 | export default () => { 247 | const [resultOf, action] = useAppContext() 248 | 249 | return ( 250 | 257 | ) 258 | } 259 | ``` 260 | 261 | ## Documentation 262 | 263 | WIP, so far there is only this `README.md` and a project `./test/env`. More docs will come with starter templates and CLI tooling. 264 | 265 | ## License 266 | 267 | [MIT](https://github.com/gluecodes/storecle/blob/master/LICENSE.md) 268 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.5" 2 | 3 | services: 4 | storecle: 5 | build: . 6 | container_name: storecle 7 | command: "yarn start" 8 | volumes: 9 | - .:/src/storecle:rw 10 | - /src/storecle/node_modules 11 | - /src/storecle/test/env/react/node_modules 12 | - /src/storecle/test/env/solid/node_modules 13 | ports: 14 | - 1234:1234 15 | - 4321:4321 16 | env_file: 17 | - .env 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@gluecodes/storecle", 3 | "version": "0.6.3", 4 | "description": "A neat uni-directional app state management for React and Solid.", 5 | "main": "./src/solid.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/gluecodes/storecle.git" 9 | }, 10 | "publishConfig": { 11 | "registry": "https://registry.npmjs.org/" 12 | }, 13 | "scripts": { 14 | "postinstall": "(cd test/env/react && yarn install); (cd test/env/solid && yarn install);", 15 | "start": "(cd test/env/react && yarn start) & (cd test/env/solid && yarn start);", 16 | "lint": "eslint . --ext .js --ext .jsx", 17 | "test:react": "export FRAMEWORK=react && mocha './test/features/**/*.js' --timeout 60000", 18 | "test:solid": "export FRAMEWORK=solid && mocha './test/features/**/*.js' --timeout 60000", 19 | "test": "yarn test:react && yarn test:solid" 20 | }, 21 | "sideEffects": false, 22 | "author": "Chris Czopp", 23 | "license": "MIT", 24 | "dependencies": { 25 | "@babel/runtime": "^7.17.9" 26 | }, 27 | "devDependencies": { 28 | "@babel/cli": "^7.17.6", 29 | "@babel/core": "^7.17.9", 30 | "@babel/plugin-transform-runtime": "^7.17.0", 31 | "@babel/preset-env": "^7.16.11", 32 | "babel-loader": "^8.2.4", 33 | "chai": "^4.3.6", 34 | "eslint": "^8.13.0", 35 | "eslint-config-standard": "^16.0.3", 36 | "eslint-config-standard-jsx": "^10.0.0", 37 | "eslint-config-standard-react": "^11.0.1", 38 | "eslint-plugin-import": "^2.26.0", 39 | "eslint-plugin-node": "^11.1.0", 40 | "eslint-plugin-prettier": "^4.0.0", 41 | "eslint-plugin-promise": "^6.0.0", 42 | "eslint-plugin-react": "^7.29.4", 43 | "eslint-plugin-react-hooks": "^4.4.0", 44 | "mocha": "^9.2.2", 45 | "prettier": "^2.6.2", 46 | "puppeteer": "^13.5.2", 47 | "webpack": "^5.72.0", 48 | "webpack-cli": "^4.9.2", 49 | "webpack-dev-server": "^4.8.1" 50 | }, 51 | "files": [ 52 | "src" 53 | ] 54 | } 55 | -------------------------------------------------------------------------------- /packageProdOverrides-react.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@gluecodes/storecle-react", 3 | "type": "module", 4 | "main": "./src/react.js", 5 | "browser": "./src/react.js", 6 | "exports": { 7 | ".": { 8 | "import": "./src/react.js", 9 | "require": "./src/react.js" 10 | } 11 | }, 12 | "scripts": { 13 | "postinstall": "exit 0" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packageProdOverrides-solid.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@gluecodes/storecle-solid", 3 | "type": "module", 4 | "main": "./src/solid.js", 5 | "browser": "./src/solid.js", 6 | "exports": { 7 | ".": { 8 | "import": "./src/solid.js", 9 | "require": "./src/solid.js" 10 | } 11 | }, 12 | "scripts": { 13 | "postinstall": "exit 0" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/common/index.js: -------------------------------------------------------------------------------- 1 | const FRAMEWORK = { 2 | react: 'react', 3 | solid: 'solid' 4 | } 5 | 6 | export const adaptForReact = (updateStore, storeRef) => ({ 7 | framework: FRAMEWORK.react, 8 | updateStore: (keyName, result) => { 9 | if (typeof keyName === 'function') { 10 | updateStore({ 11 | type: 'bulkUpdate', 12 | result: keyName(storeRef.store) 13 | }) 14 | } else if (keyName === 'runDataSuppliers') { 15 | storeRef.store[keyName] = result 16 | } else { 17 | storeRef.store[keyName] = result // React's Automatic Batching doesn't update store immediately, therefore modify ref which is passed among data suppliers 18 | updateStore({ type: keyName, result }) 19 | } 20 | } 21 | }) 22 | 23 | export const adaptForSolid = (updateStore) => ({ 24 | framework: FRAMEWORK.solid, 25 | updateStore: (keyName, result) => { 26 | if (typeof keyName === 'function') { 27 | updateStore(keyName) 28 | } else { 29 | updateStore(keyName, result) 30 | } 31 | } 32 | }) 33 | 34 | export const builtInActions = { 35 | getUserActionsBeingExecuted: [], 36 | onStoreChanged: null, 37 | runUserActions: null, 38 | runDataSuppliers: null 39 | } 40 | 41 | export default ({ 42 | dataSupplierPipeline, 43 | dataSuppliers, 44 | handleError, 45 | reloadTypes, 46 | storeRef, 47 | updateStore, 48 | userActions, 49 | userActionsBeingExecuted, 50 | framework 51 | }) => { 52 | const storeChangedEventTarget = new EventTarget() 53 | const storeChangedEventListeners = [] 54 | const userActionCounts = {} 55 | const syncSupplierUpdates = [] 56 | let shouldAbortDataSuppliers = false 57 | 58 | const userActionsProxy = new Proxy( 59 | {}, 60 | { 61 | get: 62 | (_, actionName) => 63 | (...args) => { 64 | try { 65 | if (builtInActions[actionName]) { 66 | return builtInActions[actionName](...args) 67 | } 68 | 69 | const actionBeingExecuted = userActions[actionName](...args) 70 | 71 | userActionsBeingExecuted.push(actionName) 72 | userActionCounts[actionName] = ++userActionCounts[actionName] || 1 73 | 74 | if (actionBeingExecuted instanceof Promise) { 75 | return actionBeingExecuted 76 | .then((result) => { 77 | setInStore(actionName, userActionCounts[actionName]) 78 | 79 | return result 80 | }) 81 | .catch(handleError) 82 | } 83 | 84 | setInStore(actionName, userActionCounts[actionName]) 85 | 86 | return actionBeingExecuted 87 | } catch (err) { 88 | handleError(err) 89 | } 90 | } 91 | } 92 | ) 93 | 94 | const setInStore = (actionName, result) => { 95 | updateStore(actionName, result) 96 | storeChangedEventTarget.dispatchEvent( 97 | new CustomEvent('storeChanged', { 98 | detail: { 99 | affectedKeys: [actionName] 100 | } 101 | }) 102 | ) 103 | } 104 | 105 | const liveDataSuppliers = { 106 | initialized: [], 107 | promises: {}, 108 | resolvers: {} 109 | } 110 | 111 | const incomingDataProvided = (actionName, result) => { 112 | liveDataSuppliers.resolvers[actionName](result) 113 | setInStore(actionName, result) 114 | } 115 | 116 | const getNameOfAction = (action) => 117 | Object.keys(dataSuppliers).find((actionName) => dataSuppliers[actionName] === action) || 118 | Object.keys(userActions).find((actionName) => userActions[actionName] === action) || 119 | Object.keys(builtInActions).find((actionName) => builtInActions[actionName] === action) || 120 | Object.keys(reloadTypes).find((actionName) => reloadTypes[actionName] === action) 121 | 122 | const getActionResult = (action) => { 123 | const actionName = 124 | Object.keys(dataSuppliers).find((actionName) => dataSuppliers[actionName] === action) || 125 | Object.keys(userActions).find((actionName) => userActions[actionName] === action) || 126 | Object.keys(builtInActions).find((actionName) => builtInActions[actionName] === action) 127 | 128 | if (actionName === 'getUserActionsBeingExecuted') { 129 | return userActionsBeingExecuted 130 | } 131 | 132 | const syncUpdate = 133 | framework === FRAMEWORK.react && syncSupplierUpdates.find(({ keyName }) => keyName === actionName) 134 | 135 | if (syncUpdate) { 136 | return syncUpdate.result 137 | } 138 | 139 | return storeRef.store[actionName] 140 | } 141 | 142 | const dispatchAction = (action) => { 143 | const actionName = 144 | Object.keys(userActions).find((actionName) => userActions[actionName] === action) || 145 | Object.keys(builtInActions).find((actionName) => builtInActions[actionName] === action) 146 | 147 | return userActionsProxy[actionName] 148 | } 149 | 150 | const squashSyncSupplierCalls = () => { 151 | if (syncSupplierUpdates.length > 0) { 152 | setInStore((store) => ({ 153 | ...store, 154 | ...syncSupplierUpdates.reduce((acc, { keyName, result }) => Object.assign(acc, { [keyName]: result }), {}) 155 | })) 156 | 157 | syncSupplierUpdates.length = 0 158 | } 159 | } 160 | 161 | const runDataSuppliers = async (reloadType = 'full') => { 162 | setInStore('runDataSuppliers', reloadType) 163 | 164 | try { 165 | for (const action of dataSupplierPipeline) { 166 | if (shouldAbortDataSuppliers) { 167 | return 168 | } 169 | 170 | const actionName = Object.keys(dataSuppliers).find((actionName) => dataSuppliers[actionName] === action) 171 | const actionBeingExecuted = dataSuppliers[actionName](getActionResult, getNameOfAction) 172 | 173 | if (actionBeingExecuted instanceof Promise) { 174 | setInStore(actionName, await actionBeingExecuted) 175 | } else if (typeof actionBeingExecuted === 'function') { 176 | liveDataSuppliers.promises[actionName] = new Promise((resolve, reject) => { 177 | setTimeout( 178 | () => 179 | reject( 180 | new Error( 181 | `UI data supplier: '${actionName}' didn't resolve within 20s, make sure all its preceding suppliers exist in the UI data supplier pipeline.` 182 | ) 183 | ), 184 | 20000 185 | ) 186 | liveDataSuppliers.resolvers[actionName] = resolve 187 | }) 188 | actionBeingExecuted({ 189 | asyncResults: liveDataSuppliers.promises, 190 | hasBeenInitialized: liveDataSuppliers.initialized.includes(actionName), 191 | supply: (data) => incomingDataProvided(actionName, data) 192 | }) 193 | liveDataSuppliers.initialized.push(actionName) 194 | } else { 195 | if (framework === FRAMEWORK.react) { 196 | syncSupplierUpdates.push({ keyName: actionName, result: actionBeingExecuted }) 197 | } else { 198 | setInStore(actionName, actionBeingExecuted) 199 | } 200 | } 201 | } 202 | 203 | storeChangedEventTarget.dispatchEvent( 204 | new CustomEvent('storeChanged', { 205 | detail: { 206 | affectedKeys: Object.keys(dataSuppliers).filter((actionName) => 207 | dataSupplierPipeline.find((action) => action === dataSuppliers[actionName]) 208 | ) 209 | } 210 | }) 211 | ) 212 | } catch (err) { 213 | handleError(err) 214 | } 215 | } 216 | 217 | const cleanup = () => { 218 | shouldAbortDataSuppliers = true 219 | storeChangedEventListeners.forEach((listener) => { 220 | storeChangedEventTarget.removeEventListener('storeChanged', listener) 221 | }) 222 | } 223 | 224 | Object.assign(builtInActions, { 225 | onStoreChanged: (handler) => { 226 | storeChangedEventTarget.addEventListener( 227 | 'storeChanged', 228 | storeChangedEventListeners[storeChangedEventListeners.push(handler) - 1] 229 | ) 230 | }, 231 | runUserActions: async (actions) => { 232 | try { 233 | const userActionResults = {} 234 | 235 | for (const itemToRun of actions) { 236 | const [actionName, ...args] = itemToRun 237 | const actionBeingExecuted = userActions[actionName](...args) 238 | 239 | userActionCounts[actionName] = ++userActionCounts[actionName] || 1 240 | 241 | if (actionBeingExecuted instanceof Promise) { 242 | await actionBeingExecuted 243 | } 244 | 245 | userActionResults[actionName] = userActionCounts[actionName] 246 | } 247 | 248 | updateStore((actionResults) => ({ 249 | ...actionResults, 250 | ...userActionResults 251 | })) 252 | 253 | storeChangedEventTarget.dispatchEvent( 254 | new CustomEvent('storeChanged', { 255 | detail: { 256 | affectedKeys: actions.map(([actionName]) => actionName) 257 | } 258 | }) 259 | ) 260 | } catch (err) { 261 | handleError(err) 262 | } 263 | }, 264 | runDataSuppliers 265 | }) 266 | 267 | return { 268 | context: [getActionResult, dispatchAction, getNameOfAction, cleanup], 269 | nameOf: getNameOfAction, 270 | runDataSuppliers, 271 | squashRemainingSyncSupplierCalls: squashSyncSupplierCalls 272 | } 273 | } 274 | -------------------------------------------------------------------------------- /src/react.js: -------------------------------------------------------------------------------- 1 | export { builtInActions } from './common/index' 2 | export { default as PageProvider } from './react/PageProvider' 3 | export { useAppContext } from './react/appContext' 4 | -------------------------------------------------------------------------------- /src/react/PageProvider.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react-hooks/exhaustive-deps */ 2 | import React, { useEffect, useMemo, useReducer, useRef, useState } from 'react' 3 | 4 | import initPage, { adaptForReact } from '../common/index' 5 | import { AppProvider } from './appContext' 6 | 7 | const reducer = (state, action) => { 8 | if (action.type === 'bulkUpdate') { 9 | return { 10 | ...state, 11 | ...action.result 12 | } 13 | } 14 | 15 | return { 16 | ...state, 17 | [action.type]: action.result 18 | } 19 | } 20 | 21 | export default ({ 22 | dataSupplierPipeline, 23 | dataSuppliers, 24 | getLayout, 25 | initialState = {}, 26 | reloadTypes, 27 | userActions, 28 | onError 29 | }) => { 30 | const [store, updateStore] = useReducer(reducer, { 31 | ...initialState 32 | }) 33 | 34 | const [manualRerenderFlag, triggerManualRerender] = useState(0) 35 | const userActionsBeingExecutedRef = useRef([]) 36 | const storeRef = useRef({ store }) 37 | 38 | const { context, nameOf, runDataSuppliers, squashRemainingSyncSupplierCalls } = useMemo( 39 | () => 40 | initPage({ 41 | dataSupplierPipeline, 42 | dataSuppliers, 43 | handleError: onError, 44 | reloadTypes, 45 | storeRef: storeRef.current, 46 | userActions, 47 | userActionsBeingExecuted: userActionsBeingExecutedRef.current, 48 | ...adaptForReact(updateStore, storeRef.current) 49 | }), 50 | [] 51 | ) 52 | 53 | const MemomizedLayout = useMemo( 54 | () => React.memo(getLayout(), () => userActionsBeingExecutedRef.current.length > 0), 55 | [] 56 | ) 57 | 58 | useEffect( 59 | () => { 60 | let isNonReloadingActionTrigger = false 61 | 62 | const run = async () => { 63 | await Promise.resolve() // ensures storeRef gets updated correctly when 1st supplier is sync 64 | 65 | const reloadType = Object.keys(reloadTypes).find((type) => 66 | reloadTypes[type](nameOf).some( 67 | (actionName) => 68 | userActionsBeingExecutedRef.current?.[0] === actionName && storeRef.current.store[actionName] 69 | ) 70 | ) 71 | 72 | if (reloadType || !storeRef.current.store.runDataSuppliers) { 73 | return runDataSuppliers(reloadType) 74 | } 75 | 76 | isNonReloadingActionTrigger = true 77 | } 78 | 79 | run().then(() => { 80 | userActionsBeingExecutedRef.current.shift() 81 | squashRemainingSyncSupplierCalls() 82 | 83 | if (isNonReloadingActionTrigger) { 84 | triggerManualRerender(+!manualRerenderFlag) 85 | } 86 | }) 87 | }, 88 | Object.keys(userActions).map((actionName) => store[actionName]) 89 | ) 90 | 91 | if (storeRef.current.store !== store) { 92 | storeRef.current.store = store 93 | } 94 | 95 | return ( 96 | 97 | 98 | 99 | ) 100 | } 101 | -------------------------------------------------------------------------------- /src/react/appContext.js: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from 'react' 2 | 3 | const AppContext = createContext() 4 | 5 | export const AppProvider = AppContext.Provider 6 | export const useAppContext = () => useContext(AppContext) 7 | -------------------------------------------------------------------------------- /src/solid.js: -------------------------------------------------------------------------------- 1 | export { builtInActions } from './common/index' 2 | export { default as PageProvider } from './solid/PageProvider' 3 | export { useAppContext } from './solid/appContext' 4 | -------------------------------------------------------------------------------- /src/solid/PageProvider.jsx: -------------------------------------------------------------------------------- 1 | import { createEffect } from 'solid-js' 2 | import { createStore } from 'solid-js/store' 3 | 4 | import initPage, { adaptForSolid } from '../common/index' 5 | import { AppProvider } from './appContext' 6 | 7 | export default ({ 8 | dataSupplierPipeline, 9 | dataSuppliers, 10 | initialState = {}, 11 | getLayout, 12 | reloadTypes, 13 | userActions, 14 | onError 15 | }) => { 16 | const [store, updateStore] = createStore({ 17 | ...initialState 18 | }) 19 | 20 | const userActionsBeingExecuted = [] 21 | 22 | const { context, nameOf, runDataSuppliers } = initPage({ 23 | handleError: onError, 24 | reloadTypes, 25 | storeRef: { store }, 26 | dataSupplierPipeline, 27 | dataSuppliers, 28 | userActions, 29 | userActionsBeingExecuted, 30 | ...adaptForSolid(updateStore) 31 | }) 32 | 33 | runDataSuppliers() 34 | 35 | createEffect(() => { 36 | Object.keys(userActions).forEach((actionName) => store[actionName]) 37 | 38 | const reloadType = Object.keys(reloadTypes).find((type) => 39 | reloadTypes[type](nameOf).some((actionName) => userActionsBeingExecuted?.[0] === actionName && store[actionName]) 40 | ) 41 | 42 | if (reloadType) { 43 | runDataSuppliers(reloadType).then(() => userActionsBeingExecuted.shift()) 44 | } 45 | }) 46 | 47 | return {getLayout()} 48 | } 49 | -------------------------------------------------------------------------------- /src/solid/appContext.js: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from 'solid-js' 2 | 3 | const AppContext = createContext() 4 | 5 | export const AppProvider = AppContext.Provider 6 | export const useAppContext = () => useContext(AppContext) 7 | -------------------------------------------------------------------------------- /test/env/App.jsx: -------------------------------------------------------------------------------- 1 | import { PageProvider } from '@gluecodes/storecle' 2 | 3 | import * as dataSuppliers from './actions/dataSuppliers/dataSuppliers' 4 | import * as userActions from './actions/userActions/userActions' 5 | import * as reloadTypes from './actions/reloadTypes' 6 | 7 | import Layout from './Layout' 8 | 9 | export default () => { 10 | return ( 11 | Layout} 22 | reloadTypes={reloadTypes} 23 | userActions={userActions} 24 | onError={(err) => { 25 | console.error(err) 26 | }} 27 | /> 28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /test/env/ExamplePartial.jsx: -------------------------------------------------------------------------------- 1 | import { builtInActions, useAppContext } from '@gluecodes/storecle' 2 | 3 | import { 4 | feedWithIncomingData, 5 | getThat, 6 | getThis, 7 | squash1, 8 | squash2, 9 | squash3 10 | } from './actions/dataSuppliers/dataSuppliers' 11 | 12 | import { doThat, doThis, triggerSquashedDataSuppliers } from './actions/userActions/userActions' 13 | 14 | import elementClassNames from './testHelpers/elementClassNames.json' 15 | 16 | export default () => { 17 | const [resultOf, action, nameOf] = useAppContext() 18 | 19 | return ( 20 |
21 |

Data supplying action results:

22 |
    23 |
  • {resultOf(getThis)}
  • 24 |
  • {resultOf(getThat)}
  • 25 |
  • 26 | incoming data: {resultOf(feedWithIncomingData)} 27 |
  • 28 |
29 |

User triggered actions:

30 | 38 |

{resultOf(doThis)}

39 | 47 |

{resultOf(doThat)}

48 | 56 |

57 | {resultOf(squash1)} | {resultOf(squash2)} | {resultOf(squash3)} 58 |

59 | 67 |
68 | 76 |
77 | ) 78 | } 79 | -------------------------------------------------------------------------------- /test/env/Layout.jsx: -------------------------------------------------------------------------------- 1 | import ExamplePartial from './ExamplePartial' 2 | 3 | export default () => ( 4 | <> 5 | 6 | 7 | ) 8 | -------------------------------------------------------------------------------- /test/env/actions/dataSuppliers/dataSuppliers.js: -------------------------------------------------------------------------------- 1 | export { default as getThis } from './getThis/getThis' 2 | export { default as getThat } from './getThat/getThat' 3 | export { default as feedWithIncomingData } from './feedWithIncomingData/feedWithIncomingData' 4 | export { default as squash1 } from './squash1/squash1' 5 | export { default as squash2 } from './squash2/squash2' 6 | export { default as squash3 } from './squash3/squash3' 7 | -------------------------------------------------------------------------------- /test/env/actions/dataSuppliers/feedWithIncomingData/feedWithIncomingData.js: -------------------------------------------------------------------------------- 1 | import { builtInActions } from '@gluecodes/storecle' 2 | 3 | import appChangeHistorySnapshotTypes from '../../../testHelpers/appChangeHistorySnapshotTypes.json' 4 | 5 | const COUNTER_INITIAL_VALUE = 10 6 | 7 | export default function feedWithIncomingData (resultOf) { 8 | return ({ hasBeenInitialized, supply }) => { 9 | if (!hasBeenInitialized) { 10 | builtInActions.onStoreChanged((e) => { 11 | // console.log(e.detail.affectedKeys) 12 | }) 13 | 14 | let count = COUNTER_INITIAL_VALUE 15 | 16 | global.addEventListener('message', (e) => { 17 | if (e.data === 'triggerIncomingData') { 18 | count -= 1 19 | supply(count) 20 | } 21 | }) 22 | 23 | global.sessionStorage.setItem( 24 | appChangeHistorySnapshotTypes.incomingDataSupplierInitializations, 25 | +global.sessionStorage.getItem(appChangeHistorySnapshotTypes.incomingDataSupplierInitializations) + 1 26 | ) 27 | } 28 | 29 | supply(resultOf(feedWithIncomingData) || COUNTER_INITIAL_VALUE) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /test/env/actions/dataSuppliers/getThat/getThat.js: -------------------------------------------------------------------------------- 1 | import getThis from '../getThis/getThis' 2 | import appChangeHistorySnapshotTypes from '../../../testHelpers/appChangeHistorySnapshotTypes.json' 3 | 4 | export default (resultOf) => { 5 | global.sessionStorage.setItem( 6 | appChangeHistorySnapshotTypes.secondDataSupplierTriggers, 7 | +global.sessionStorage.getItem( 8 | appChangeHistorySnapshotTypes.secondDataSupplierTriggers 9 | ) + 1 10 | ) 11 | 12 | return `result of getThat which accessed ${resultOf(getThis)}` 13 | } 14 | -------------------------------------------------------------------------------- /test/env/actions/dataSuppliers/getThis/getThis.js: -------------------------------------------------------------------------------- 1 | import { builtInActions } from '@gluecodes/storecle' 2 | 3 | import { triggeredByDoThat } from '../../reloadTypes' 4 | import appChangeHistorySnapshotTypes from '../../../testHelpers/appChangeHistorySnapshotTypes.json' 5 | 6 | export default async function getThis(resultOf, nameOf) { 7 | global.sessionStorage.setItem( 8 | appChangeHistorySnapshotTypes.firstDataSupplierTriggers, 9 | +global.sessionStorage.getItem(appChangeHistorySnapshotTypes.firstDataSupplierTriggers) + 1 10 | ) 11 | 12 | if (resultOf(builtInActions.runDataSuppliers) === nameOf(triggeredByDoThat)) { 13 | global.sessionStorage.setItem( 14 | appChangeHistorySnapshotTypes.firstDataSupplierCachedResults, 15 | +global.sessionStorage.getItem(appChangeHistorySnapshotTypes.firstDataSupplierCachedResults) + 1 16 | ) 17 | 18 | return resultOf(getThis) 19 | } 20 | 21 | return 'result of getThis' 22 | } 23 | -------------------------------------------------------------------------------- /test/env/actions/dataSuppliers/squash1/squash1.js: -------------------------------------------------------------------------------- 1 | import { builtInActions } from '@gluecodes/storecle' 2 | import { runSquashedDataSuppliers } from '../../reloadTypes' 3 | 4 | export default function squash1(resultOf, nameOf) { 5 | if (resultOf(builtInActions.runDataSuppliers) !== nameOf(runSquashedDataSuppliers)) { 6 | return resultOf(squash1) 7 | } 8 | 9 | return 'result of squash1' 10 | } 11 | -------------------------------------------------------------------------------- /test/env/actions/dataSuppliers/squash2/squash2.js: -------------------------------------------------------------------------------- 1 | import { builtInActions } from '@gluecodes/storecle' 2 | import { runSquashedDataSuppliers } from '../../reloadTypes' 3 | import squash1 from '../squash1/squash1' 4 | 5 | const syncWait = (ms) => { 6 | const end = Date.now() + ms 7 | 8 | while (Date.now() < end) continue 9 | } 10 | 11 | export default function squash2(resultOf, nameOf) { 12 | if (resultOf(builtInActions.runDataSuppliers) !== nameOf(runSquashedDataSuppliers)) { 13 | return resultOf(squash2) 14 | } 15 | 16 | syncWait(500) 17 | return `${resultOf(squash1)}, result of squash2` 18 | } 19 | -------------------------------------------------------------------------------- /test/env/actions/dataSuppliers/squash3/squash3.js: -------------------------------------------------------------------------------- 1 | import { builtInActions } from '@gluecodes/storecle' 2 | import { runSquashedDataSuppliers } from '../../reloadTypes' 3 | import squash1 from '../squash1/squash1' 4 | import squash2 from '../squash2/squash2' 5 | 6 | const syncWait = (ms) => { 7 | const end = Date.now() + ms 8 | 9 | while (Date.now() < end) continue 10 | } 11 | 12 | export default function squash3(resultOf, nameOf) { 13 | if (resultOf(builtInActions.runDataSuppliers) !== nameOf(runSquashedDataSuppliers)) { 14 | return resultOf(squash3) 15 | } 16 | 17 | syncWait(500) 18 | return `${resultOf(squash1)}, ${resultOf(squash2)}, result of squash3` 19 | } 20 | -------------------------------------------------------------------------------- /test/env/actions/reloadTypes.js: -------------------------------------------------------------------------------- 1 | import { doThat, triggerSquashedDataSuppliers } from './userActions/userActions' 2 | 3 | export const triggeredByDoThat = (nameOf) => [nameOf(doThat)] 4 | 5 | export const runSquashedDataSuppliers = (nameOf) => [nameOf(triggerSquashedDataSuppliers)] 6 | -------------------------------------------------------------------------------- /test/env/actions/userActions/doThat/doThat.js: -------------------------------------------------------------------------------- 1 | export default () => { 2 | return 'done that' 3 | } 4 | -------------------------------------------------------------------------------- /test/env/actions/userActions/doThis/doThis.js: -------------------------------------------------------------------------------- 1 | export default async () => { 2 | return 'done this' 3 | } 4 | -------------------------------------------------------------------------------- /test/env/actions/userActions/triggerSquashedDataSuppliers/triggerSquashedDataSuppliers.js: -------------------------------------------------------------------------------- 1 | export default async () => {} 2 | -------------------------------------------------------------------------------- /test/env/actions/userActions/userActions.js: -------------------------------------------------------------------------------- 1 | export { default as doThis } from './doThis/doThis' 2 | export { default as doThat } from './doThat/doThat' 3 | export { default as triggerSquashedDataSuppliers } from './triggerSquashedDataSuppliers/triggerSquashedDataSuppliers' 4 | -------------------------------------------------------------------------------- /test/env/react/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "targets": { 7 | "browsers": [ 8 | "last 2 versions" 9 | ] 10 | }, 11 | "shippedProposals": true 12 | } 13 | ], 14 | "@babel/preset-react" 15 | ], 16 | "plugins": [ 17 | "@babel/plugin-transform-runtime" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /test/env/react/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = (api) => { 2 | api.cache(true) 3 | 4 | const presets = [ 5 | [ 6 | '@babel/preset-env', { 7 | targets: { 8 | browsers: [ 9 | 'last 2 versions', 10 | 'edge >= 16' 11 | ] 12 | }, 13 | shippedProposals: true 14 | } 15 | ], 16 | ['@babel/preset-react', { 17 | runtime: 'automatic' 18 | }] 19 | ] 20 | 21 | const plugins = [ 22 | '@babel/plugin-transform-runtime', 23 | '@babel/plugin-proposal-export-namespace-from' 24 | ] 25 | 26 | return { 27 | presets, 28 | plugins 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /test/env/react/index.js: -------------------------------------------------------------------------------- 1 | import { createRoot } from 'react-dom/client' 2 | import App from '../App' 3 | 4 | const root = createRoot(global.document.getElementById('app')) 5 | 6 | root.render() 7 | -------------------------------------------------------------------------------- /test/env/react/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "start": "../../../node_modules/.bin/webpack-dev-server --config ../webpack.config.js --mode development", 4 | "build": "../../../node_modules/.bin/webpack --config ../webpack.config.js --mode development", 5 | "test": "cd ../../../ && yarn test:react" 6 | }, 7 | "devDependencies": { 8 | "@babel/preset-react": "^7.16.7" 9 | }, 10 | "dependencies": { 11 | "react": "^18.0.0", 12 | "react-dom": "^18.0.0" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /test/env/react/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | "@babel/helper-annotate-as-pure@^7.16.7": 6 | version "7.16.7" 7 | resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.16.7.tgz#bb2339a7534a9c128e3102024c60760a3a7f3862" 8 | integrity sha512-s6t2w/IPQVTAET1HitoowRGXooX8mCgtuP5195wD/QJPV6wYjpujCGF7JuMODVX2ZAJOf1GT6DT9MHEZvLOFSw== 9 | dependencies: 10 | "@babel/types" "^7.16.7" 11 | 12 | "@babel/helper-module-imports@^7.16.7": 13 | version "7.16.7" 14 | resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.16.7.tgz#25612a8091a999704461c8a222d0efec5d091437" 15 | integrity sha512-LVtS6TqjJHFc+nYeITRo6VLXve70xmq7wPhWTqDJusJEgGmkAACWwMiTNrvfoQo6hEhFwAIixNkvB0jPXDL8Wg== 16 | dependencies: 17 | "@babel/types" "^7.16.7" 18 | 19 | "@babel/helper-plugin-utils@^7.16.7": 20 | version "7.16.7" 21 | resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.16.7.tgz#aa3a8ab4c3cceff8e65eb9e73d87dc4ff320b2f5" 22 | integrity sha512-Qg3Nk7ZxpgMrsox6HreY1ZNKdBq7K72tDSliA6dCl5f007jR4ne8iD5UzuNnCJH2xBf2BEEVGr+/OL6Gdp7RxA== 23 | 24 | "@babel/helper-validator-identifier@^7.16.7": 25 | version "7.16.7" 26 | resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.16.7.tgz#e8c602438c4a8195751243da9031d1607d247cad" 27 | integrity sha512-hsEnFemeiW4D08A5gUAZxLBTXpZ39P+a+DGDsHw1yxqyQ/jzFEnxf5uTEGp+3bzAbNOxU1paTgYS4ECU/IgfDw== 28 | 29 | "@babel/helper-validator-option@^7.16.7": 30 | version "7.16.7" 31 | resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.16.7.tgz#b203ce62ce5fe153899b617c08957de860de4d23" 32 | integrity sha512-TRtenOuRUVo9oIQGPC5G9DgK4743cdxvtOw0weQNpZXaS16SCBi5MNjZF8vba3ETURjZpTbVn7Vvcf2eAwFozQ== 33 | 34 | "@babel/plugin-syntax-jsx@^7.16.7": 35 | version "7.16.7" 36 | resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.16.7.tgz#50b6571d13f764266a113d77c82b4a6508bbe665" 37 | integrity sha512-Esxmk7YjA8QysKeT3VhTXvF6y77f/a91SIs4pWb4H2eWGQkCKFgQaG6hdoEVZtGsrAcb2K5BW66XsOErD4WU3Q== 38 | dependencies: 39 | "@babel/helper-plugin-utils" "^7.16.7" 40 | 41 | "@babel/plugin-transform-react-display-name@^7.16.7": 42 | version "7.16.7" 43 | resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.16.7.tgz#7b6d40d232f4c0f550ea348593db3b21e2404340" 44 | integrity sha512-qgIg8BcZgd0G/Cz916D5+9kqX0c7nPZyXaP8R2tLNN5tkyIZdG5fEwBrxwplzSnjC1jvQmyMNVwUCZPcbGY7Pg== 45 | dependencies: 46 | "@babel/helper-plugin-utils" "^7.16.7" 47 | 48 | "@babel/plugin-transform-react-jsx-development@^7.16.7": 49 | version "7.16.7" 50 | resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.16.7.tgz#43a00724a3ed2557ed3f276a01a929e6686ac7b8" 51 | integrity sha512-RMvQWvpla+xy6MlBpPlrKZCMRs2AGiHOGHY3xRwl0pEeim348dDyxeH4xBsMPbIMhujeq7ihE702eM2Ew0Wo+A== 52 | dependencies: 53 | "@babel/plugin-transform-react-jsx" "^7.16.7" 54 | 55 | "@babel/plugin-transform-react-jsx@^7.16.7": 56 | version "7.17.3" 57 | resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.17.3.tgz#eac1565da176ccb1a715dae0b4609858808008c1" 58 | integrity sha512-9tjBm4O07f7mzKSIlEmPdiE6ub7kfIe6Cd+w+oQebpATfTQMAgW+YOuWxogbKVTulA+MEO7byMeIUtQ1z+z+ZQ== 59 | dependencies: 60 | "@babel/helper-annotate-as-pure" "^7.16.7" 61 | "@babel/helper-module-imports" "^7.16.7" 62 | "@babel/helper-plugin-utils" "^7.16.7" 63 | "@babel/plugin-syntax-jsx" "^7.16.7" 64 | "@babel/types" "^7.17.0" 65 | 66 | "@babel/plugin-transform-react-pure-annotations@^7.16.7": 67 | version "7.16.7" 68 | resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.16.7.tgz#232bfd2f12eb551d6d7d01d13fe3f86b45eb9c67" 69 | integrity sha512-hs71ToC97k3QWxswh2ElzMFABXHvGiJ01IB1TbYQDGeWRKWz/MPUTh5jGExdHvosYKpnJW5Pm3S4+TA3FyX+GA== 70 | dependencies: 71 | "@babel/helper-annotate-as-pure" "^7.16.7" 72 | "@babel/helper-plugin-utils" "^7.16.7" 73 | 74 | "@babel/preset-react@^7.16.7": 75 | version "7.16.7" 76 | resolved "https://registry.yarnpkg.com/@babel/preset-react/-/preset-react-7.16.7.tgz#4c18150491edc69c183ff818f9f2aecbe5d93852" 77 | integrity sha512-fWpyI8UM/HE6DfPBzD8LnhQ/OcH8AgTaqcqP2nGOXEUV+VKBR5JRN9hCk9ai+zQQ57vtm9oWeXguBCPNUjytgA== 78 | dependencies: 79 | "@babel/helper-plugin-utils" "^7.16.7" 80 | "@babel/helper-validator-option" "^7.16.7" 81 | "@babel/plugin-transform-react-display-name" "^7.16.7" 82 | "@babel/plugin-transform-react-jsx" "^7.16.7" 83 | "@babel/plugin-transform-react-jsx-development" "^7.16.7" 84 | "@babel/plugin-transform-react-pure-annotations" "^7.16.7" 85 | 86 | "@babel/types@^7.16.7", "@babel/types@^7.17.0": 87 | version "7.17.0" 88 | resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.17.0.tgz#a826e368bccb6b3d84acd76acad5c0d87342390b" 89 | integrity sha512-TmKSNO4D5rzhL5bjWFcVHHLETzfQ/AmbKpKPOSjlP0WoHZ6L911fgoOKY4Alp/emzG4cHJdyN49zpgkbXFEHHw== 90 | dependencies: 91 | "@babel/helper-validator-identifier" "^7.16.7" 92 | to-fast-properties "^2.0.0" 93 | 94 | "js-tokens@^3.0.0 || ^4.0.0": 95 | version "4.0.0" 96 | resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" 97 | integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== 98 | 99 | loose-envify@^1.1.0: 100 | version "1.4.0" 101 | resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" 102 | integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== 103 | dependencies: 104 | js-tokens "^3.0.0 || ^4.0.0" 105 | 106 | react-dom@^18.0.0: 107 | version "18.0.0" 108 | resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.0.0.tgz#26b88534f8f1dbb80853e1eabe752f24100d8023" 109 | integrity sha512-XqX7uzmFo0pUceWFCt7Gff6IyIMzFUn7QMZrbrQfGxtaxXZIcGQzoNpRLE3fQLnS4XzLLPMZX2T9TRcSrasicw== 110 | dependencies: 111 | loose-envify "^1.1.0" 112 | scheduler "^0.21.0" 113 | 114 | react@^18.0.0: 115 | version "18.0.0" 116 | resolved "https://registry.yarnpkg.com/react/-/react-18.0.0.tgz#b468736d1f4a5891f38585ba8e8fb29f91c3cb96" 117 | integrity sha512-x+VL6wbT4JRVPm7EGxXhZ8w8LTROaxPXOqhlGyVSrv0sB1jkyFGgXxJ8LVoPRLvPR6/CIZGFmfzqUa2NYeMr2A== 118 | dependencies: 119 | loose-envify "^1.1.0" 120 | 121 | scheduler@^0.21.0: 122 | version "0.21.0" 123 | resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.21.0.tgz#6fd2532ff5a6d877b6edb12f00d8ab7e8f308820" 124 | integrity sha512-1r87x5fz9MXqswA2ERLo0EbOAU74DpIUO090gIasYTqlVoJeMcl+Z1Rg7WHz+qtPujhS/hGIt9kxZOYBV3faRQ== 125 | dependencies: 126 | loose-envify "^1.1.0" 127 | 128 | to-fast-properties@^2.0.0: 129 | version "2.0.0" 130 | resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e" 131 | integrity sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4= 132 | -------------------------------------------------------------------------------- /test/env/solid/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = (api) => { 2 | api.cache(true) 3 | 4 | const presets = [ 5 | [ 6 | '@babel/preset-env', { 7 | targets: { 8 | browsers: [ 9 | 'last 2 versions', 10 | 'edge >= 16' 11 | ] 12 | }, 13 | shippedProposals: true 14 | } 15 | ], 16 | 'solid' 17 | ] 18 | 19 | const plugins = [ 20 | '@babel/plugin-transform-runtime', 21 | '@babel/plugin-proposal-export-namespace-from' 22 | ] 23 | 24 | return { 25 | presets, 26 | plugins 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /test/env/solid/index.js: -------------------------------------------------------------------------------- 1 | import { render } from 'solid-js/web' 2 | import App from '../App' 3 | 4 | render(() => , global.document.getElementById('app')) 5 | -------------------------------------------------------------------------------- /test/env/solid/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "start": "../../../node_modules/.bin/webpack-dev-server --config ../webpack.config.js --mode development", 4 | "build": "../../../node_modules/.bin/webpack --config ../webpack.config.js --mode development", 5 | "test": "cd ../../../ && yarn test:solid" 6 | }, 7 | "devDependencies": { 8 | "babel-preset-solid": "^1.3.13" 9 | }, 10 | "dependencies": { 11 | "solid-js": "^1.3.15" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /test/env/solid/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | "@babel/helper-module-imports@7.16.0": 6 | version "7.16.0" 7 | resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.16.0.tgz#90538e60b672ecf1b448f5f4f5433d37e79a3ec3" 8 | integrity sha512-kkH7sWzKPq0xt3H1n+ghb4xEMP8k0U7XV3kkB+ZGy69kDk2ySFW1qPi06sjKzFY3t1j6XbJSqr4mF9L7CYVyhg== 9 | dependencies: 10 | "@babel/types" "^7.16.0" 11 | 12 | "@babel/helper-plugin-utils@^7.16.7": 13 | version "7.16.7" 14 | resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.16.7.tgz#aa3a8ab4c3cceff8e65eb9e73d87dc4ff320b2f5" 15 | integrity sha512-Qg3Nk7ZxpgMrsox6HreY1ZNKdBq7K72tDSliA6dCl5f007jR4ne8iD5UzuNnCJH2xBf2BEEVGr+/OL6Gdp7RxA== 16 | 17 | "@babel/helper-validator-identifier@^7.16.7": 18 | version "7.16.7" 19 | resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.16.7.tgz#e8c602438c4a8195751243da9031d1607d247cad" 20 | integrity sha512-hsEnFemeiW4D08A5gUAZxLBTXpZ39P+a+DGDsHw1yxqyQ/jzFEnxf5uTEGp+3bzAbNOxU1paTgYS4ECU/IgfDw== 21 | 22 | "@babel/plugin-syntax-jsx@^7.16.5": 23 | version "7.16.7" 24 | resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.16.7.tgz#50b6571d13f764266a113d77c82b4a6508bbe665" 25 | integrity sha512-Esxmk7YjA8QysKeT3VhTXvF6y77f/a91SIs4pWb4H2eWGQkCKFgQaG6hdoEVZtGsrAcb2K5BW66XsOErD4WU3Q== 26 | dependencies: 27 | "@babel/helper-plugin-utils" "^7.16.7" 28 | 29 | "@babel/types@^7.16.0": 30 | version "7.17.0" 31 | resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.17.0.tgz#a826e368bccb6b3d84acd76acad5c0d87342390b" 32 | integrity sha512-TmKSNO4D5rzhL5bjWFcVHHLETzfQ/AmbKpKPOSjlP0WoHZ6L911fgoOKY4Alp/emzG4cHJdyN49zpgkbXFEHHw== 33 | dependencies: 34 | "@babel/helper-validator-identifier" "^7.16.7" 35 | to-fast-properties "^2.0.0" 36 | 37 | babel-plugin-jsx-dom-expressions@^0.32.11: 38 | version "0.32.11" 39 | resolved "https://registry.yarnpkg.com/babel-plugin-jsx-dom-expressions/-/babel-plugin-jsx-dom-expressions-0.32.11.tgz#089f062a2089a781d85c517962eb2d4788e58ea6" 40 | integrity sha512-hytqY33SGW6B3obSLt8K5X510UwtNkTktCCWgwba+QOOV0CowDFiqeL+0ru895FLacFaYANHFTu1y76dg3GVtw== 41 | dependencies: 42 | "@babel/helper-module-imports" "7.16.0" 43 | "@babel/plugin-syntax-jsx" "^7.16.5" 44 | "@babel/types" "^7.16.0" 45 | html-entities "2.3.2" 46 | 47 | babel-preset-solid@^1.3.13: 48 | version "1.3.13" 49 | resolved "https://registry.yarnpkg.com/babel-preset-solid/-/babel-preset-solid-1.3.13.tgz#312373712a0492ff9e561ecb7a97aa7079aa018b" 50 | integrity sha512-MZnmsceI9yiHlwwFCSALTJhadk2eea/+2UP4ec4jkPZFR+XRKTLoIwRkrBh7uLtvHF+3lHGyUaXtZukOmmUwhA== 51 | dependencies: 52 | babel-plugin-jsx-dom-expressions "^0.32.11" 53 | 54 | html-entities@2.3.2: 55 | version "2.3.2" 56 | resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-2.3.2.tgz#760b404685cb1d794e4f4b744332e3b00dcfe488" 57 | integrity sha512-c3Ab/url5ksaT0WyleslpBEthOzWhrjQbg75y7XUsfSzi3Dgzt0l8w5e7DylRn15MTlMMD58dTfzddNS2kcAjQ== 58 | 59 | solid-js@^1.3.15: 60 | version "1.3.15" 61 | resolved "https://registry.yarnpkg.com/solid-js/-/solid-js-1.3.15.tgz#1d79d377fd2cbb810be1bb45b9d57157842f63e0" 62 | integrity sha512-tghvvwstKQWC3RIkIT1xf70gQx7+oxYeQ2BR/Y0MrCF4+icen/xBwy3nJ0fUNuP58QCbFmszK6TmMturD/sNrA== 63 | 64 | to-fast-properties@^2.0.0: 65 | version "2.0.0" 66 | resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e" 67 | integrity sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4= 68 | -------------------------------------------------------------------------------- /test/env/testHelpers/appChangeHistorySnapshotTypes.json: -------------------------------------------------------------------------------- 1 | { 2 | "firstDataSupplierCachedResults": "firstDataSupplierCachedResults", 3 | "firstDataSupplierTriggers": "firstDataSupplierTriggers", 4 | "incomingDataSupplierInitializations": "incomingDataSupplierInitializations", 5 | "lastDomMutation": "lastDomMutation", 6 | "secondDataSupplierTriggers": "secondDataSupplierTriggers" 7 | } 8 | -------------------------------------------------------------------------------- /test/env/testHelpers/appPort.js: -------------------------------------------------------------------------------- 1 | module.exports = process.env.FRAMEWORK === 'react' ? 1234 : 4321 2 | -------------------------------------------------------------------------------- /test/env/testHelpers/elementClassNames.json: -------------------------------------------------------------------------------- 1 | { 2 | "bulkUserActionTrigger": "bulkUserActionTrigger", 3 | "firstDataSupplierResult": "firstDataSupplierResult", 4 | "firstUserActionResult": "firstUserActionResult", 5 | "firstUserActionTrigger": "firstUserActionTrigger", 6 | "incomingDataSupplyTrigger": "incomingDataSupplyTrigger", 7 | "incomingDataSupplierResult": "incomingDataSupplierResult", 8 | "secondDataSupplierResult": "secondDataSupplierResult", 9 | "secondUserActionResult": "secondUserActionResult", 10 | "secondUserActionTrigger": "secondUserActionTrigger", 11 | "squashedSupplierCallActionResult": "squashedSupplierCallActionResult", 12 | "triggerSquashedDataSuppliers": "triggerSquashedDataSuppliers" 13 | } 14 | -------------------------------------------------------------------------------- /test/env/testHelpers/handleAppChangeHistoryRequest.js: -------------------------------------------------------------------------------- 1 | global.document.addEventListener('fetchAppChangeHistory', (e) => { 2 | console.log('$' + global.sessionStorage.getItem(e.detail.snapshotType)) 3 | }) 4 | -------------------------------------------------------------------------------- /test/env/testHelpers/registerMutationObserver.js: -------------------------------------------------------------------------------- 1 | import appChangeHistorySnapshotTypes from './appChangeHistorySnapshotTypes.json' 2 | 3 | const targetNode = global.document.getElementById('app') 4 | 5 | const config = { 6 | attributes: true, 7 | characterData: true, 8 | childList: true, 9 | subtree: true 10 | } 11 | 12 | const observer = new MutationObserver((mutationsList) => { 13 | for (const mutation of mutationsList) { 14 | global.sessionStorage.setItem( 15 | appChangeHistorySnapshotTypes.lastDomMutation, 16 | JSON.stringify({ 17 | type: mutation.type, 18 | ...(mutation.type === 'childList' 19 | ? { 20 | affectedElementClassName: mutation.target.getAttribute('class'), 21 | innerText: mutation.target.innerText.trim() 22 | } 23 | : {}), 24 | ...(mutation.type === 'characterData' 25 | ? { 26 | affectedText: mutation.target.nodeValue, 27 | parentElementClassName: mutation.target.parentNode.getAttribute('class') 28 | } 29 | : {}) 30 | }) 31 | ) 32 | } 33 | }) 34 | 35 | observer.observe(targetNode, config) 36 | -------------------------------------------------------------------------------- /test/env/webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack') 2 | const path = require('path') 3 | 4 | const cwd = path.basename(process.cwd()) 5 | 6 | const getPageHtml = (frameworkName = cwd) => ` 7 | 8 | 9 | ${frameworkName} sandbox 10 | 11 | 12 | 13 |
14 | 15 | 16 | 17 | ` 18 | 19 | module.exports = { 20 | target: 'web', 21 | entry: { 22 | index: [ 23 | path.resolve(__dirname, './testHelpers/handleAppChangeHistoryRequest.js'), 24 | path.resolve(__dirname, './testHelpers/registerMutationObserver.js'), 25 | path.resolve(__dirname, `./${cwd}/index.js`) 26 | ] 27 | }, 28 | module: { 29 | rules: [ 30 | { 31 | test: /\.(js|jsx)$/, 32 | exclude: /(node_modules)/, 33 | use: { 34 | loader: 'babel-loader', 35 | options: { 36 | configFile: path.resolve(__dirname, `./${cwd}/babel.config.js`) 37 | } 38 | } 39 | } 40 | ] 41 | }, 42 | plugins: [ 43 | new webpack.ProvidePlugin({ 44 | React: 'react' 45 | }) 46 | ], 47 | output: { 48 | filename: 'bundles/[name].bundle.js', 49 | chunkFilename: 'bundles/[name]-[chunkhash].chunk.js', 50 | path: path.resolve(__dirname, `./${cwd}/dist/`), 51 | libraryTarget: 'umd', 52 | globalObject: 'this' 53 | }, 54 | devServer: { 55 | host: '0.0.0.0', 56 | port: { react: 1234, solid: 4321 }[cwd], 57 | static: { 58 | directory: path.resolve(__dirname, `./${cwd}/dist/`) 59 | }, 60 | hot: true, 61 | liveReload: false, 62 | onBeforeSetupMiddleware: ({ app }) => { 63 | app.get('/', (req, res) => { 64 | res.send(getPageHtml()) 65 | }) 66 | } 67 | }, 68 | watchOptions: { 69 | aggregateTimeout: 1000, 70 | poll: 3000 71 | }, 72 | resolve: { 73 | extensions: ['.js', '.jsx', '.json', '.mjs'], 74 | alias: { 75 | '@gluecodes/storecle': path.resolve(__dirname, `../../src/${cwd}.js`), 76 | react: path.resolve(__dirname, `${cwd}/node_modules/react`), 77 | 'solid-js': path.resolve(__dirname, `${cwd}/node_modules/solid-js`) 78 | } 79 | }, 80 | ...(process.env.NODE_ENV === 'local' 81 | ? { 82 | devtool: 'source-map' 83 | } 84 | : {}) 85 | } 86 | -------------------------------------------------------------------------------- /test/features/appStateManipulation.js: -------------------------------------------------------------------------------- 1 | const { expect } = require('chai') 2 | const setupEnv = require('../runner/index') 3 | const appPort = require('../env/testHelpers/appPort') 4 | const appChangeHistorySnapshotTypes = require('../env/testHelpers/appChangeHistorySnapshotTypes.json') 5 | const elementClassNames = require('../env/testHelpers/elementClassNames.json') 6 | 7 | describe('app state manipulation', () => { 8 | let env 9 | 10 | beforeEach(async () => { 11 | env = await setupEnv({ 12 | url: `http://localhost:${appPort}` 13 | }) 14 | }) 15 | 16 | afterEach(async () => { 17 | await env.done() 18 | }) 19 | 20 | it('should render initial DOM', async () => { 21 | const incomingDataSupplierInitializationsCount = await env.fetchAppChangeHistory({ 22 | snapshotType: appChangeHistorySnapshotTypes.incomingDataSupplierInitializations 23 | }) 24 | 25 | const firstDataSupplierTriggersCount = await env.fetchAppChangeHistory({ 26 | snapshotType: appChangeHistorySnapshotTypes.firstDataSupplierTriggers 27 | }) 28 | 29 | const secondDataSupplierTriggersCount = await env.fetchAppChangeHistory({ 30 | snapshotType: appChangeHistorySnapshotTypes.secondDataSupplierTriggers 31 | }) 32 | 33 | const firstDataSupplierCachedResultsCount = await env.fetchAppChangeHistory({ 34 | snapshotType: appChangeHistorySnapshotTypes.firstDataSupplierCachedResults 35 | }) 36 | 37 | expect( 38 | await env.document.querySelector(`.${elementClassNames.firstDataSupplierResult}`).innerText.promise() 39 | ).to.equal('result of getThis') 40 | expect( 41 | await env.document.querySelector(`.${elementClassNames.secondDataSupplierResult}`).innerText.promise() 42 | ).to.equal('result of getThat which accessed result of getThis') 43 | expect( 44 | await env.document.querySelector(`.${elementClassNames.incomingDataSupplierResult}`).innerText.promise() 45 | ).to.equal('incoming data: 10') 46 | expect( 47 | await env.document.querySelector(`.${elementClassNames.firstUserActionResult}`).innerText.promise() 48 | ).to.equal('') 49 | expect( 50 | await env.document.querySelector(`.${elementClassNames.secondUserActionResult}`).innerText.promise() 51 | ).to.equal('') 52 | 53 | expect(incomingDataSupplierInitializationsCount).to.equal(1) 54 | expect(firstDataSupplierTriggersCount).to.equal(1) 55 | expect(secondDataSupplierTriggersCount).to.equal(1) 56 | expect(firstDataSupplierCachedResultsCount).to.equal(null) 57 | 58 | // await env.page.screenshot({ path: `${__dirname}/screenshot.png`, fullPage: true }) 59 | }) 60 | 61 | it('should trigger user action', async () => { 62 | await env.page.click(`.${elementClassNames.firstUserActionTrigger}`) 63 | 64 | const lastDomMutation = await env.fetchAppChangeHistory({ 65 | snapshotType: appChangeHistorySnapshotTypes.lastDomMutation 66 | }) 67 | 68 | expect( 69 | await env.document.querySelector(`.${elementClassNames.firstUserActionResult}`).innerText.promise() 70 | ).to.equal('1') 71 | 72 | expect(lastDomMutation.type).to.equal('childList') 73 | expect(lastDomMutation.affectedElementClassName).to.equal(elementClassNames.firstUserActionResult) 74 | expect(lastDomMutation.innerText).to.equal('1') 75 | 76 | expect( 77 | await env.document.querySelector(`.${elementClassNames.secondUserActionResult}`).innerText.promise() 78 | ).to.equal('') 79 | }) 80 | 81 | it('should trigger user action which reloads data suppliers', async () => { 82 | await env.page.click(`.${elementClassNames.secondUserActionTrigger}`) 83 | 84 | const lastDomMutation = await env.fetchAppChangeHistory({ 85 | snapshotType: appChangeHistorySnapshotTypes.lastDomMutation 86 | }) 87 | 88 | const firstDataSupplierTriggersCount = await env.fetchAppChangeHistory({ 89 | snapshotType: appChangeHistorySnapshotTypes.firstDataSupplierTriggers 90 | }) 91 | 92 | const secondDataSupplierTriggersCount = await env.fetchAppChangeHistory({ 93 | snapshotType: appChangeHistorySnapshotTypes.secondDataSupplierTriggers 94 | }) 95 | 96 | const firstDataSupplierCachedResultsCount = await env.fetchAppChangeHistory({ 97 | snapshotType: appChangeHistorySnapshotTypes.firstDataSupplierCachedResults 98 | }) 99 | 100 | expect( 101 | await env.document.querySelector(`.${elementClassNames.secondUserActionResult}`).innerText.promise() 102 | ).to.equal('1') 103 | 104 | expect(lastDomMutation.type).to.equal('childList') 105 | expect(lastDomMutation.affectedElementClassName).to.equal(elementClassNames.secondUserActionResult) 106 | expect(lastDomMutation.innerText).to.equal('1') 107 | 108 | expect(firstDataSupplierTriggersCount).to.equal(2) 109 | expect(secondDataSupplierTriggersCount).to.equal(2) 110 | expect(firstDataSupplierCachedResultsCount).to.equal(1) 111 | 112 | expect( 113 | await env.document.querySelector(`.${elementClassNames.firstUserActionResult}`).innerText.promise() 114 | ).to.equal('') 115 | }) 116 | 117 | it('should trigger incoming data event', async () => { 118 | for (const number of ['9', '8', '7']) { 119 | await env.page.click(`.${elementClassNames.incomingDataSupplyTrigger}`) 120 | 121 | const lastDomMutation = await env.fetchAppChangeHistory({ 122 | snapshotType: appChangeHistorySnapshotTypes.lastDomMutation 123 | }) 124 | 125 | const incomingDataSupplierInitializationsCount = await env.fetchAppChangeHistory({ 126 | snapshotType: appChangeHistorySnapshotTypes.incomingDataSupplierInitializations 127 | }) 128 | 129 | expect( 130 | await env.document.querySelector(`.${elementClassNames.incomingDataSupplierResult}`).innerText.promise() 131 | ).to.equal(`incoming data: ${number}`) 132 | 133 | expect(lastDomMutation.type).to.equal('characterData') 134 | expect(lastDomMutation.parentElementClassName).to.equal(elementClassNames.incomingDataSupplierResult) 135 | expect(lastDomMutation.affectedText).to.equal(number) 136 | 137 | expect( 138 | await env.document.querySelector(`.${elementClassNames.firstUserActionResult}`).innerText.promise() 139 | ).to.equal('') 140 | 141 | expect( 142 | await env.document.querySelector(`.${elementClassNames.secondUserActionResult}`).innerText.promise() 143 | ).to.equal('') 144 | 145 | expect(incomingDataSupplierInitializationsCount).to.equal(1) 146 | } 147 | }) 148 | 149 | it('should store user action count', async () => { 150 | await env.page.click(`.${elementClassNames.firstUserActionTrigger}`) 151 | await env.page.click(`.${elementClassNames.firstUserActionTrigger}`) 152 | await env.page.click(`.${elementClassNames.firstUserActionTrigger}`) 153 | 154 | const lastDomMutation = await env.fetchAppChangeHistory({ 155 | snapshotType: appChangeHistorySnapshotTypes.lastDomMutation 156 | }) 157 | 158 | expect( 159 | await env.document.querySelector(`.${elementClassNames.firstUserActionResult}`).innerText.promise() 160 | ).to.equal('3') 161 | 162 | expect(lastDomMutation.type).to.equal('characterData') 163 | expect(lastDomMutation.parentElementClassName).to.equal(elementClassNames.firstUserActionResult) 164 | expect(lastDomMutation.affectedText).to.equal('3') 165 | }) 166 | 167 | it('should run user actions in bulk', async () => { 168 | await env.page.click(`.${elementClassNames.bulkUserActionTrigger}`) 169 | 170 | expect( 171 | await env.document.querySelector(`.${elementClassNames.firstUserActionResult}`).innerText.promise() 172 | ).to.equal('1') 173 | 174 | expect( 175 | await env.document.querySelector(`.${elementClassNames.secondUserActionResult}`).innerText.promise() 176 | ).to.equal('1') 177 | }) 178 | 179 | it('should update DOM just once for squashed data supplier runs', async () => { 180 | const expectedText = 181 | 'result of squash1 | result of squash1, result of squash2 | result of squash1, result of squash1, result of squash2, result of squash3' 182 | 183 | expect( 184 | await env.document.querySelector(`.${elementClassNames.squashedSupplierCallActionResult}`).innerText.promise() 185 | ).to.equal('| |') 186 | 187 | await env.page.click(`.${elementClassNames.triggerSquashedDataSuppliers}`) 188 | 189 | expect( 190 | await env.document.querySelector(`.${elementClassNames.squashedSupplierCallActionResult}`).innerText.promise() 191 | ).to.equal(expectedText) 192 | 193 | const lastDomMutation = await env.fetchAppChangeHistory({ 194 | snapshotType: appChangeHistorySnapshotTypes.lastDomMutation 195 | }) 196 | 197 | if (lastDomMutation.type === 'childList') { 198 | expect(lastDomMutation.innerText).to.equal(expectedText) 199 | } else if (lastDomMutation.type === 'characterData') { 200 | expect( 201 | await env.document.querySelector(`.${lastDomMutation.parentElementClassName}`).innerText.promise() 202 | ).to.equal(expectedText) 203 | } 204 | }) 205 | }) 206 | -------------------------------------------------------------------------------- /test/runner/index.js: -------------------------------------------------------------------------------- 1 | const puppeteer = require('puppeteer') 2 | 3 | const getInDocEvaluator = (page) => { 4 | const docMock = {} 5 | const callChain = [] 6 | 7 | const identifierName = new Proxy({}, { 8 | get: (target, prop) => { 9 | if (typeof prop === 'string' && prop !== '__proto__') { 10 | return prop 11 | } 12 | 13 | return null 14 | } 15 | }) 16 | 17 | const trappedFunction = function (identifier, argList) { 18 | callChain.pop() 19 | callChain.push({ type: 'call', id: identifier, argList }) 20 | return getDocProxy({}) 21 | } 22 | 23 | const promiseFunction = () => new Promise((resolve, reject) => ( 24 | page.evaluate((serializedCallChain) => ( 25 | serializedCallChain.reduce((acc, callNode) => { 26 | if (callNode.type === 'call') { 27 | return acc[callNode.id].apply(acc, callNode.argList) 28 | } 29 | 30 | return acc[callNode.id] 31 | }, document) 32 | ), callChain) 33 | .then(resolve) 34 | .catch(reject) 35 | )) 36 | 37 | const getNextInChainProxy = identifier => new Proxy(trappedFunction, { 38 | apply: (target, thisArg, argList) => { 39 | if (identifier === 'promise') { 40 | callChain.pop() 41 | return promiseFunction() 42 | } 43 | 44 | return target(identifier, argList) 45 | }, 46 | get: (target, prop) => getDocProxy({})[prop] 47 | }) 48 | 49 | const getDocProxy = object => new Proxy(object, { 50 | get: (target, prop) => { 51 | if (prop === 'then') { return } 52 | 53 | if (target === docMock) { 54 | callChain.length = 0 55 | } 56 | 57 | callChain.push({ type: 'getter', id: identifierName[prop] }) 58 | return getNextInChainProxy(identifierName[prop]) 59 | } 60 | }) 61 | 62 | return getDocProxy(docMock) 63 | } 64 | 65 | module.exports = async ({ 66 | url, 67 | waitForSelector = '#app > div' 68 | }) => { 69 | const browser = await puppeteer.launch({ args: ['--no-sandbox', '--disable-setuid-sandbox'] }) 70 | const page = await browser.newPage() 71 | let lastAppDataHandler 72 | 73 | const fetchCssClasses = async () => ( 74 | page.evaluate(() => ( 75 | Array.from(document.querySelectorAll('[class]')) 76 | .reduce((acc, node) => acc.concat(Array.from(node.classList)), []) 77 | .reduce((acc, className) => ({ ...acc, [className]: className }), {}) 78 | )) 79 | ) 80 | 81 | const fetchAppChangeHistory = async (payload) => { 82 | page.evaluate((serializedPayload) => { 83 | document.dispatchEvent(new CustomEvent('fetchAppChangeHistory', { 84 | bubbles: true, 85 | cancelable: false, 86 | detail: serializedPayload 87 | })) 88 | }, payload) 89 | } 90 | 91 | /* page.on('console', (msg) => { 92 | const logBatch = msg.text() 93 | 94 | if (!/^\$/.test(logBatch)) { 95 | console.log(logBatch) 96 | } 97 | }) */ 98 | 99 | page.on('error', (err) => { 100 | console.error(err) 101 | browser.close() 102 | }) 103 | 104 | await page.setViewport({ width: 1920, height: 1080 }) 105 | await page.goto(url) 106 | await page.waitForSelector(waitForSelector) 107 | 108 | const cssClasses = await fetchCssClasses(page) 109 | 110 | return { 111 | cssClasses, 112 | document: getInDocEvaluator(page), 113 | done: async () => browser.close(), 114 | goTo: async (url) => { 115 | await page.goto(url) 116 | await page.waitForSelector(waitForSelector) 117 | Object.assign(cssClasses, await fetchCssClasses(page)) 118 | }, 119 | page, 120 | fetchAppChangeHistory: async (payload) => new Promise((resolve, reject) => { 121 | if (lastAppDataHandler) { 122 | page.off('console', lastAppDataHandler) 123 | } 124 | 125 | page.on('console', lastAppDataHandler = (msg) => { 126 | const logBatch = msg.text() 127 | 128 | if (/^\$/.test(logBatch)) { 129 | resolve(JSON.parse(logBatch.slice(1))) 130 | } 131 | }) 132 | 133 | fetchAppChangeHistory(payload) 134 | .catch(reject) 135 | }), 136 | updateCssClasses: async () => { 137 | Object.assign(cssClasses, await fetchCssClasses(page)) 138 | } 139 | } 140 | } 141 | --------------------------------------------------------------------------------