├── .babelrc ├── .eslintrc.js ├── .github └── workflows │ └── publish.yml ├── .gitignore ├── .prettierrc.js ├── LICENSE ├── README.md ├── configs ├── jest.json ├── jest.preprocessor.js └── webpack │ ├── common.js │ ├── dev.js │ └── prod.js ├── express.js ├── package.json ├── src ├── components │ ├── App.tsx │ ├── app.css │ ├── atom │ │ ├── ComputeErrorMessage.tsx │ │ ├── ComputeResult.tsx │ │ ├── ErrorBoundary.tsx │ │ └── Loading.tsx │ ├── molecules │ │ ├── SomeInput.tsx │ │ └── VirtualList.tsx │ └── organisms │ │ ├── SomeListBlocking.tsx │ │ ├── SomeListDedicatedWorker.tsx │ │ ├── SomeListSingleton.tsx │ │ └── SomeListWorkerPool.tsx ├── contexts │ └── AppCtx.ts ├── index.html.ejs ├── index.tsx └── workers │ ├── compute.ts │ ├── compute.worker.singleton.ts │ └── compute.worker.ts ├── tests ├── App.test.tsx └── __mocks__ │ ├── fileMock.js │ ├── shim.js │ └── styleMock.js ├── tsconfig.json └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["@babel/preset-env", { 4 | "loose": true, 5 | "modules": "cjs" 6 | }], 7 | "@babel/preset-react" 8 | ], 9 | "plugins": [ 10 | "react-hot-loader/babel", 11 | "@babel/plugin-transform-modules-commonjs" 12 | ], 13 | "env": { 14 | "production": { 15 | "presets": ["minify"] 16 | }, 17 | "test": { 18 | "presets": ["@babel/preset-env", "@babel/preset-react"] 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | 'plugin:react/recommended', // Uses the recommended rules from @eslint-plugin-react 4 | 'plugin:@typescript-eslint/recommended', // Uses the recommended rules from the @typescript-eslint/eslint-plugin 5 | 'prettier/@typescript-eslint', // Uses eslint-config-prettier to disable ESLint rules from @typescript-eslint/eslint-plugin that would conflict with prettier 6 | 'plugin:prettier/recommended', // Enables eslint-plugin-prettier and eslint-config-prettier. This will display prettier errors as ESLint errors. Make sure this is always the last configuration in the extends array. 7 | ], 8 | parser: '@typescript-eslint/parser', 9 | parserOptions: { 10 | project: 'tsconfig.json', 11 | sourceType: 'module', 12 | ecmaVersion: 2020, 13 | ecmaFeatures: { 14 | jsx: true, // Allows for the parsing of JSX 15 | }, 16 | }, 17 | plugins: ['@typescript-eslint'], 18 | settings: { 19 | react: { 20 | version: 'detect', // Tells eslint-plugin-react to automatically detect the version of React to use 21 | }, 22 | }, 23 | // Fine tune rules 24 | rules: { 25 | '@typescript-eslint/no-var-requires': 0, 26 | '@typescript-eslint/no-explicit-any': 0, 27 | '@typescript-eslint/explicit-module-boundary-types': 0, 28 | '@typescript-eslint/ban-ts-comment': 0, 29 | }, 30 | } 31 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Publish Github Page 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [12.x] 20 | 21 | steps: 22 | - uses: actions/checkout@v2 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v1 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | - run: yarn 28 | - run: yarn build 29 | - name: Deploy GitHub Page 30 | uses: peaceiris/actions-gh-pages@v3 31 | with: 32 | github_token: ${{ secrets.GITHUB_TOKEN }} 33 | publish_dir: ./dist 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | dist/ 3 | node_modules/ 4 | src/**/*.jsx 5 | tests/__coverage__/ 6 | tests/**/*.jsx 7 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: false, 3 | trailingComma: 'all', 4 | singleQuote: true, 5 | overrides: [ 6 | { 7 | files: ['*.md'], 8 | options: { 9 | printWidth: 90, 10 | proseWrap: 'always', 11 | }, 12 | }, 13 | ], 14 | } 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Viktor Persson 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 | # React Offload UI Thread Research 2 | 3 | When we develop a frontend application, we undoubtedly offload all kinds of I/O, 4 | computational tasks from UI thread to prevent UI thread is too busy and become 5 | unresponsive. **However, this rule doesn't apply to current web development**. The 6 | current web developmennt **ONLY** offload the tasks to web worker when the application 7 | encounter performance issues but **NOT** by the tasks' nature. 8 | 9 | [Live Demo](https://gaplo917.github.io/react-offload-ui-thread-research/?v=2) 10 | 11 | ### 120Hz is coming 12 | 13 | 120Hz Web browsing is coming, the higher fps the shorter time for UI thread to process. 14 | Consider the 60Hz to 120Hz change, the "smooth UI" process cycle time changed from 16.67ms 15 | to 8.33ms, that halve the time we got from previous decade! 16 | 17 | > An I/O call is non-blocking on UI thread doesn't mean that it doesn't use the UI thread 18 | > CPU time. 19 | 20 | In addition, the business requirements, validations, UI events also become more and more 21 | complex. If you need to build a hassle-free smooth 120Hz Web application, using web workers 22 | are unavoidable. 23 | 24 | ### Painful and Time-consuming web worker development 25 | 26 | Because of the learning curve of web workers, we are tempted to do everything 27 | on UI thread like: 28 | 29 | - calling fragmented REST call and then **aggregate together** 30 | - calling a large GraphQL query and then apply **data transformation** 31 | - sorting, filtering and reduce differents kinds of data triggered by UI actions 32 | 33 | It is because: 34 | 35 | - creating a web-worker and import existing npm modules into web worker is **painful**. 36 | - coding in a complete message driven style is **not intuitive, time-consuming, and repetitive**. 37 | - working in an **async UI pattern** requires more state to handle it. 38 | 39 | ### Upcoming Possibility 40 | 41 | As the [ComLink abstraction](https://github.com/GoogleChromeLabs/comlink)(turn a web 42 | worker to RPC-style function call) and 43 | [React Concurrent mode](https://reactjs.org/docs/concurrent-mode-intro.html) arise. I 44 | think it is time to start thinking to adopt web worker **_in all cases_** - **completely 45 | decouple a data accessing layer**, use browser's background thread for 46 | **ALL** I/O and data processing bu nature and then return to the UI thread for rendering. 47 | 48 | Nothing is new, this was how we wrote a standard frontend application in other 49 | platforms(iOS, Android, Windows, macOS, JVM) since multi-threaded CPU appeared. 50 | 51 | This project is a 52 | [Comlink loader (Webpack)](https://github.com/GoogleChromeLabs/comlink-loader) decision 53 | research. 54 | 55 | To access the complete research findings(WIP), you could access in 56 | [patreon](https://www.patreon.com/gaplotech). 57 | 58 | ## Getting Started 59 | 60 | [![Edit gaplo917/react-offload-ui-thread-research](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/github/gaplo917/react-offload-ui-thread-research/tree/master/?fontsize=14&hidenavigation=1) 61 | 62 | or try locally 63 | 64 | ``` 65 | git clone https://github.com/gaplo917/react-offload-ui-thread-research.git 66 | 67 | cd react-offload-ui-thread-research 68 | yarn intall 69 | 70 | # start the demo 71 | yarn start 72 | ``` 73 | -------------------------------------------------------------------------------- /configs/jest.json: -------------------------------------------------------------------------------- 1 | { 2 | "rootDir": "..", 3 | "coverageDirectory": "/tests/__coverage__/", 4 | "setupFiles": [ 5 | "/tests/__mocks__/shim.js" 6 | ], 7 | "roots": [ 8 | "/src/", 9 | "/tests/" 10 | ], 11 | "moduleNameMapper": { 12 | "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/tests/__mocks__/fileMock.js", 13 | "\\.(css|scss|less)$": "/tests/__mocks__/styleMock.js" 14 | }, 15 | "moduleFileExtensions": ["ts", "tsx", "js", "jsx"], 16 | "transform": { 17 | "^.+\\.(ts|tsx)$": "/configs/jest.preprocessor.js" 18 | }, 19 | "transformIgnorePatterns": [ 20 | "/node_modules/" 21 | ], 22 | "testRegex": "/tests/.*\\.(ts|tsx)$", 23 | "moduleDirectories": [ 24 | "node_modules" 25 | ], 26 | "globals": { 27 | "DEVELOPMENT": false, 28 | "FAKE_SERVER": false 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /configs/jest.preprocessor.js: -------------------------------------------------------------------------------- 1 | const tsc = require('typescript'); 2 | const tsConfig = require('./../tsconfig.json'); 3 | 4 | module.exports = { 5 | process(src, path) { 6 | const isTs = path.endsWith('.ts'); 7 | const isTsx = path.endsWith('.tsx'); 8 | const isTypescriptFile = (isTs || isTsx); 9 | 10 | if ( isTypescriptFile ) { 11 | return tsc.transpileModule( 12 | src, 13 | { 14 | compilerOptions: tsConfig.compilerOptions, 15 | fileName: path 16 | } 17 | ).outputText; 18 | } 19 | 20 | return src; 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /configs/webpack/common.js: -------------------------------------------------------------------------------- 1 | // shared config (dev and prod) 2 | const { resolve } = require('path') 3 | const HtmlWebpackPlugin = require('html-webpack-plugin') 4 | 5 | module.exports = { 6 | resolve: { 7 | extensions: ['.ts', '.tsx', '.js', '.jsx'], 8 | }, 9 | context: resolve(__dirname, '../../src'), 10 | module: { 11 | rules: [ 12 | { 13 | test: /\.worker\.singleton\.ts$/i, 14 | use: [ 15 | { 16 | loader: 'comlink-loader', 17 | options: { 18 | singleton: true, 19 | }, 20 | }, 21 | ], 22 | }, 23 | { 24 | test: /\.tsx?$/, 25 | use: 'ts-loader', 26 | exclude: /node_modules/, 27 | }, 28 | { 29 | test: /\.js$/, 30 | use: ['babel-loader', 'source-map-loader'], 31 | exclude: /node_modules/, 32 | }, 33 | { 34 | test: /\.css$/, 35 | use: [ 36 | 'style-loader', 37 | { loader: 'css-loader', options: { importLoaders: 1 } }, 38 | ], 39 | }, 40 | ], 41 | }, 42 | plugins: [new HtmlWebpackPlugin({ template: 'index.html.ejs' })], 43 | externals: { 44 | react: 'React', 45 | 'react-dom': 'ReactDOM', 46 | }, 47 | performance: { 48 | hints: false, 49 | }, 50 | } 51 | -------------------------------------------------------------------------------- /configs/webpack/dev.js: -------------------------------------------------------------------------------- 1 | // development config 2 | const merge = require("webpack-merge"); 3 | const webpack = require("webpack"); 4 | const commonConfig = require("./common"); 5 | 6 | module.exports = merge(commonConfig, { 7 | mode: "development", 8 | entry: [ 9 | "react-hot-loader/patch", // activate HMR for React 10 | "webpack-dev-server/client?http://localhost:8080", // bundle the client for webpack-dev-server and connect to the provided endpoint 11 | "webpack/hot/only-dev-server", // bundle the client for hot reloading, only- means to only hot reload for successful updates 12 | "./index.tsx", // the entry point of our app 13 | ], 14 | devServer: { 15 | hot: true, // enable HMR on the server 16 | disableHostCheck: true, 17 | host: "0.0.0.0", 18 | }, 19 | devtool: "cheap-module-eval-source-map", 20 | plugins: [ 21 | new webpack.HotModuleReplacementPlugin(), // enable HMR globally 22 | ], 23 | }); 24 | -------------------------------------------------------------------------------- /configs/webpack/prod.js: -------------------------------------------------------------------------------- 1 | // production config 2 | const merge = require('webpack-merge') 3 | const { resolve } = require('path') 4 | 5 | const commonConfig = require('./common') 6 | 7 | module.exports = merge(commonConfig, { 8 | mode: 'production', 9 | entry: './index.tsx', 10 | output: { 11 | filename: 'js/bundle.[hash].min.js', 12 | path: resolve(__dirname, '../../dist'), 13 | publicPath: '/react-offload-ui-thread-research/', 14 | }, 15 | devtool: 'source-map', 16 | plugins: [], 17 | }) 18 | -------------------------------------------------------------------------------- /express.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const app = express(); 3 | const portNumber = 3000; 4 | const sourceDir = 'dist'; 5 | 6 | app.use(express.static(sourceDir)); 7 | 8 | app.listen(portNumber, () => { 9 | console.log(`Express web server started: http://localhost:${portNumber}`); 10 | console.log(`Serving content from /${sourceDir}/`); 11 | }); 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-webpack-typescript-starter", 3 | "version": "0.1.0", 4 | "description": "Starter kit for React, Webpack (with Hot Module Replacement), Typescript and Babel.", 5 | "keywords": [ 6 | "react", 7 | "webpack", 8 | "typescript", 9 | "babel", 10 | "sass", 11 | "hmr", 12 | "starter", 13 | "boilerplate" 14 | ], 15 | "author": "Viktor Persson", 16 | "license": "MIT", 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/vikpe/react-webpack-typescript-starter.git" 20 | }, 21 | "bugs": { 22 | "url": "https://github.com/vikpe/react-webpack-typescript-starter/issues" 23 | }, 24 | "homepage": "https://github.com/vikpe/react-webpack-typescript-starter", 25 | "scripts": { 26 | "build": "yarn run clean-dist && webpack -p --config=configs/webpack/prod.js", 27 | "clean-dist": "rimraf dist/*", 28 | "lint": "eslint './src/**/*.{js,ts,tsx}' --quiet", 29 | "start": "yarn run start-dev", 30 | "start-dev": "webpack-dev-server --config=configs/webpack/dev.js", 31 | "start-prod": "yarn run build && node express.js", 32 | "test": "jest --coverage --watchAll --config=configs/jest.json" 33 | }, 34 | "devDependencies": { 35 | "@babel/cli": "^7.10.5", 36 | "@babel/core": "^7.10.5", 37 | "@babel/plugin-transform-modules-commonjs": "^7.10.4", 38 | "@babel/preset-env": "^7.10.4", 39 | "@babel/preset-react": "^7.10.4", 40 | "@types/node": "^14.0.23", 41 | "@types/react": "^16.9.43", 42 | "@types/react-dom": "^16.9.8", 43 | "@typescript-eslint/eslint-plugin": "^3.6.1", 44 | "@typescript-eslint/parser": "^3.6.1", 45 | "babel-loader": "^8.1.0", 46 | "css-loader": "^3.6.0", 47 | "eslint": "^7.5.0", 48 | "eslint-config-prettier": "^6.11.0", 49 | "eslint-plugin-prettier": "^3.1.4", 50 | "eslint-plugin-react": "^7.20.3", 51 | "express": "^4.17.1", 52 | "html-webpack-plugin": "^4.3.0", 53 | "prettier": "^2.0.5", 54 | "react": "^17.0.0-rc.1", 55 | "react-dom": "^17.0.0-rc.1", 56 | "react-hot-loader": "^4.12.21", 57 | "rimraf": "^3.0.2", 58 | "style-loader": "^1.2.1", 59 | "ts-loader": "^8.0.3", 60 | "typescript": "^3.9.7", 61 | "uglifyjs-webpack-plugin": "^2.2.0", 62 | "webpack": "^4.43.0", 63 | "webpack-cli": "^3.3.12", 64 | "webpack-dev-middleware": "^3.7.2", 65 | "webpack-dev-server": "^3.11.0", 66 | "webpack-merge": "^4.2.2" 67 | }, 68 | "dependencies": { 69 | "@material-ui/core": "^4.11.0", 70 | "@types/react-virtualized": "^9.21.10", 71 | "comlink-loader": "^2.0.0", 72 | "react-suspendable-contract": "^0.2.0", 73 | "react-virtualized": "^9.22.2" 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/components/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import { hot } from 'react-hot-loader' 3 | import SomeListSingleton from './organisms/SomeListSingleton' 4 | import SomeListBlocking from './organisms/SomeListBlocking' 5 | import SomeInput from './molecules/SomeInput' 6 | import { AppCtx, AppMode, InputModel } from '../contexts/AppCtx' 7 | import CircularProgress from '@material-ui/core/CircularProgress' 8 | import SomeListDedicatedWorker from './organisms/SomeListDedicatedWorker' 9 | import SomeListWorkerPool from './organisms/SomeListWorkerPool' 10 | import { compute } from '../workers/compute' 11 | import { Box } from '@material-ui/core' 12 | import './app.css' 13 | 14 | const ModeSwitcher = ({ mode }: { mode: AppMode }) => ( 15 | <> 16 | {mode === AppMode.blocking && } 17 | {mode === AppMode.webWorkerSingleton && } 18 | {mode === AppMode.webWorkerDedicated && } 19 | {mode === AppMode.webWorkerPool && } 20 | 21 | ) 22 | 23 | const Header = () => ( 24 | <> 25 |

React Offload UI Thread Research

26 | 27 |

28 | 29 | * The progress loading dialog is to track UI blocking occurrence 30 | visually 31 | 32 |

33 |
34 |

35 | Parameters 36 |

37 |

38 | Start tuning the parameters to see the UI blocking!{' '} 39 |

40 | 41 | 42 | ) 43 | 44 | const FunctionPreview = ({ input }: { input: InputModel }) => ( 45 | 46 |
 47 |       

48 | {'//'} each compute run with {input.base}^{input.pow}( 49 | {Math.ceil(Math.pow(input.base, input.pow))}) iterations. 50 |

51 | {String(compute)} 52 |
53 |
54 | ) 55 | 56 | const Footer = () => ( 57 | <> 58 |

More

59 |

60 | GitHub Repository:{' '} 61 | 66 | https://github.com/gaplo917/react-offload-ui-thread-research 67 | 68 |

69 |

70 | For detail explanations and more technical R&D, you can visit{' '} 71 | 76 | https://www.patreon.com/gaplotech 77 | 78 |

79 | 80 | ) 81 | function App() { 82 | const [input, setInput] = useState({ 83 | base: 150, 84 | pow: 2.7, 85 | rowCount: 300, 86 | }) 87 | const [mode, setMode] = useState(AppMode.blocking) 88 | 89 | return ( 90 | 91 |
92 |
93 | 94 | 95 |
96 |
97 |
98 | ) 99 | } 100 | 101 | declare let module: Record 102 | 103 | export default hot(module)(App) 104 | -------------------------------------------------------------------------------- /src/components/app.css: -------------------------------------------------------------------------------- 1 | @media (max-width: 700px) and (-webkit-min-device-pixel-ratio: 2) { 2 | html { 3 | font-size: 60%; 4 | zoom: 0.6; 5 | } 6 | } 7 | @media (max-width: 700px) and (-webkit-min-device-pixel-ratio: 3) { 8 | html { 9 | font-size: 60%; 10 | zoom: 0.6; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/components/atom/ComputeErrorMessage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | interface ComputeErrorMessageProps { 4 | index: number 5 | base: number 6 | pow: number 7 | } 8 | 9 | const ComputeErrorMessage = ({ 10 | index, 11 | base, 12 | pow, 13 | }: ComputeErrorMessageProps) => ( 14 | 15 | {index}. cannot load {base},{pow} 16 | 17 | ) 18 | 19 | export default ComputeErrorMessage 20 | -------------------------------------------------------------------------------- /src/components/atom/ComputeResult.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | interface ComputeResultProps { 4 | index: number 5 | base: number 6 | pow: number 7 | result: number 8 | } 9 | 10 | const ComputeResult = ({ index, base, pow, result }: ComputeResultProps) => ( 11 | 12 | {index}. compute({base}, {pow}) = {result} 13 | 14 | ) 15 | 16 | export default ComputeResult 17 | -------------------------------------------------------------------------------- /src/components/atom/ErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from 'react' 2 | 3 | interface ErrorBoundaryProps { 4 | fallback: ReactNode 5 | } 6 | 7 | export class ErrorBoundary extends React.Component { 8 | state: { hasError: boolean; error: any | null } = { 9 | hasError: false, 10 | error: null, 11 | } 12 | static getDerivedStateFromError(error: unknown) { 13 | return { 14 | hasError: true, 15 | error, 16 | } 17 | } 18 | render() { 19 | if (this.state.hasError) { 20 | return this.props.fallback 21 | } 22 | return this.props.children 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/components/atom/Loading.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const Loading = ({ index }: { index: number }) => {index}. loading 4 | 5 | export default Loading 6 | -------------------------------------------------------------------------------- /src/components/molecules/SomeInput.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react' 2 | import { makeStyles } from '@material-ui/core/styles' 3 | import TextField from '@material-ui/core/TextField' 4 | import { AppCtx, AppMode } from '../../contexts/AppCtx' 5 | import { FormControl } from '@material-ui/core' 6 | import InputLabel from '@material-ui/core/InputLabel' 7 | import Select from '@material-ui/core/Select' 8 | 9 | const useStyles = makeStyles((theme) => ({ 10 | root: { 11 | '& > *': { 12 | margin: theme.spacing(1), 13 | width: '25ch', 14 | }, 15 | }, 16 | })) 17 | 18 | export default function SomeInput() { 19 | const classes = useStyles() 20 | const appCtx = useContext(AppCtx) 21 | if (appCtx === null) { 22 | return null 23 | } 24 | const { input, setInput, mode, setMode } = appCtx 25 | 26 | return ( 27 |
28 | ) => { 37 | setInput({ 38 | ...input, 39 | base: Number(event.target.value), 40 | }) 41 | }} 42 | /> 43 | ) => { 52 | setInput({ 53 | ...input, 54 | pow: Number(event.target.value), 55 | }) 56 | }} 57 | /> 58 | ) => { 67 | setInput({ 68 | ...input, 69 | rowCount: Number(event.target.value), 70 | }) 71 | }} 72 | /> 73 | 74 | Mode 75 | 95 | 96 | 97 | ) 98 | } 99 | -------------------------------------------------------------------------------- /src/components/molecules/VirtualList.tsx: -------------------------------------------------------------------------------- 1 | import { makeStyles } from '@material-ui/core/styles' 2 | import React, { useContext } from 'react' 3 | import { AppCtx } from '../../contexts/AppCtx' 4 | import { List, ListRowRenderer } from 'react-virtualized' 5 | import CircularProgress from '@material-ui/core/CircularProgress' 6 | 7 | const useStyles = makeStyles((theme) => ({ 8 | root: { 9 | flexGrow: 1, 10 | backgroundColor: theme.palette.background.paper, 11 | textAlign: 'left', 12 | }, 13 | })) 14 | 15 | export default function VirtualList({ 16 | headerComp, 17 | rowRendererProvider, 18 | }: { 19 | headerComp?: () => JSX.Element 20 | rowRendererProvider: (base: number, pow: number) => ListRowRenderer 21 | }) { 22 | const classes = useStyles() 23 | const appCtx = useContext(AppCtx) 24 | 25 | if (appCtx === null) { 26 | return null 27 | } 28 | 29 | const { input } = appCtx 30 | 31 | const { base, pow } = input 32 | 33 | return ( 34 |
35 |

36 | Virtual List with {input.rowCount} Items 37 |

38 | {headerComp && headerComp()} 39 |

Scroll fastly to see the UI blocking

40 | 48 |
49 | ) 50 | } 51 | -------------------------------------------------------------------------------- /src/components/organisms/SomeListBlocking.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react' 2 | import { compute } from '../../workers/compute' 3 | import VirtualList from '../molecules/VirtualList' 4 | import Loading from '../atom/Loading' 5 | import ComputeResult from '../atom/ComputeResult' 6 | 7 | interface TabContentProps { 8 | index: number 9 | base: number 10 | pow: number 11 | style: React.CSSProperties 12 | isScrolling: boolean 13 | } 14 | 15 | function TabContent({ index, base, pow, style, isScrolling }: TabContentProps) { 16 | const result = isScrolling ? ( 17 | 18 | ) : ( 19 | useMemo(() => { 20 | const result = compute(base, pow) 21 | return ( 22 | 23 | ) 24 | }, [base, pow]) 25 | ) 26 | return

{result}

27 | } 28 | 29 | export default function SomeListBlocking() { 30 | return ( 31 | ({ 33 | key, 34 | index, 35 | style, 36 | isScrolling, 37 | }) => ( 38 | 46 | )} 47 | /> 48 | ) 49 | } 50 | -------------------------------------------------------------------------------- /src/components/organisms/SomeListDedicatedWorker.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react' 2 | // @ts-ignore 3 | import ComputeWorker from 'comlink-loader!../../workers/compute.worker' 4 | import { Suspendable, useSuspendableData } from 'react-suspendable-contract' 5 | import { ErrorBoundary } from '../atom/ErrorBoundary' 6 | import VirtualList from '../molecules/VirtualList' 7 | import Loading from '../atom/Loading' 8 | import ComputeResult from '../atom/ComputeResult' 9 | import ComputeErrorMessage from '../atom/ComputeErrorMessage' 10 | 11 | interface TabContentProps { 12 | index: number 13 | base: number 14 | pow: number 15 | style: React.CSSProperties 16 | } 17 | 18 | // no way to dispose the worker if using a comlink-loader, until this pr is merged 19 | // https://github.com/GoogleChromeLabs/comlink-loader/pull/27 20 | function TabContent({ index, base, pow, style }: TabContentProps) { 21 | const suspendableData = useSuspendableData(async () => { 22 | const worker = new ComputeWorker() 23 | return worker.compute(base, pow) 24 | }, [base, pow]) 25 | 26 | return ( 27 |

28 | } 31 | > 32 | }> 33 | 34 | {(data) => ( 35 | 41 | )} 42 | 43 | 44 | 45 |

46 | ) 47 | } 48 | 49 | export default function SomeListDedicatedWorker() { 50 | return ( 51 | ({ key, index, style }) => ( 53 | 60 | )} 61 | /> 62 | ) 63 | } 64 | -------------------------------------------------------------------------------- /src/components/organisms/SomeListSingleton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { compute } from '../../workers/compute.worker.singleton' 3 | import { Suspendable, useSuspendableData } from 'react-suspendable-contract' 4 | import { ErrorBoundary } from '../atom/ErrorBoundary' 5 | import VirtualList from '../molecules/VirtualList' 6 | import Loading from '../atom/Loading' 7 | import ComputeResult from '../atom/ComputeResult' 8 | import ComputeErrorMessage from '../atom/ComputeErrorMessage' 9 | 10 | interface TabContentProps { 11 | index: number 12 | base: number 13 | pow: number 14 | style: React.CSSProperties 15 | } 16 | 17 | function TabContent({ index, base, pow, style }: TabContentProps) { 18 | const suspendableData = useSuspendableData(() => compute(base, pow), [ 19 | base, 20 | pow, 21 | ]) 22 | return ( 23 |

24 | } 27 | > 28 | }> 29 | 30 | {(data) => ( 31 | 37 | )} 38 | 39 | 40 | 41 |

42 | ) 43 | } 44 | 45 | export default function SomeListSingleton() { 46 | return ( 47 | ({ key, index, style }) => ( 49 | 56 | )} 57 | /> 58 | ) 59 | } 60 | -------------------------------------------------------------------------------- /src/components/organisms/SomeListWorkerPool.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo, useState } from 'react' 2 | // @ts-ignore 3 | import ComputeWorker from 'comlink-loader!../../workers/compute.worker' 4 | import { Suspendable, useSuspendableData } from 'react-suspendable-contract' 5 | import { ErrorBoundary } from '../atom/ErrorBoundary' 6 | import VirtualList from '../molecules/VirtualList' 7 | import TextField from '@material-ui/core/TextField' 8 | import Loading from '../atom/Loading' 9 | import ComputeResult from '../atom/ComputeResult' 10 | import ComputeErrorMessage from '../atom/ComputeErrorMessage' 11 | 12 | interface TabContentProps { 13 | index: number 14 | base: number 15 | pow: number 16 | style: React.CSSProperties 17 | worker: ComputeWorker 18 | } 19 | 20 | function TabContent({ index, base, pow, style, worker }: TabContentProps) { 21 | const suspendableData = useSuspendableData( 22 | () => worker.compute(base, pow), 23 | [base, pow], 24 | ) 25 | return ( 26 |

27 | } 30 | > 31 | }> 32 | 33 | {(data) => ( 34 | 40 | )} 41 | 42 | 43 | 44 |

45 | ) 46 | } 47 | 48 | export default function SomeListWorkerPool() { 49 | const [poolSize, setPoolSize] = useState(4) 50 | 51 | const workerPool = useMemo( 52 | () => new Array(poolSize).fill(null).map(() => new ComputeWorker()), 53 | [poolSize], 54 | ) 55 | 56 | return ( 57 | <> 58 | ( 60 | ) => { 69 | setPoolSize(Number(event.target.value)) 70 | }} 71 | /> 72 | )} 73 | rowRendererProvider={(base, pow) => ({ key, index, style }) => ( 74 | 82 | )} 83 | /> 84 | 85 | ) 86 | } 87 | -------------------------------------------------------------------------------- /src/contexts/AppCtx.ts: -------------------------------------------------------------------------------- 1 | import React, { Dispatch, SetStateAction } from 'react' 2 | 3 | export interface InputModel { 4 | base: number 5 | pow: number 6 | rowCount: number 7 | } 8 | 9 | export enum AppMode { 10 | blocking, 11 | webWorkerSingleton, 12 | webWorkerDedicated, 13 | webWorkerPool, 14 | } 15 | 16 | export const AppCtx = React.createContext<{ 17 | input: InputModel 18 | setInput: Dispatch> 19 | mode: AppMode 20 | setMode: Dispatch> 21 | } | null>(null) 22 | -------------------------------------------------------------------------------- /src/index.html.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | React Offload UI Thread Research 7 | 8 | 9 |
10 | 11 | 12 | <% if (webpackConfig.mode == 'production') { %> 13 | 14 | 15 | <% } else { %> 16 | 17 | 18 | <% } %> 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { render } from 'react-dom' 3 | import App from './components/App' 4 | 5 | const rootEl = document.getElementById('root') 6 | 7 | render(, rootEl) 8 | 9 | // TODO: adopt React concurrent mode when it become stable 10 | -------------------------------------------------------------------------------- /src/workers/compute.ts: -------------------------------------------------------------------------------- 1 | // noting special, just a compute task that could use CPU 2 | export function compute(base: number, pow: number): number { 3 | let result = 0 4 | let i = 0 5 | const len = Math.pow(base, pow) 6 | while (i < len) { 7 | result += Math.sin(i) * Math.sin(i) + Math.cos(i) * Math.cos(i) 8 | i++ 9 | } 10 | 11 | return result 12 | } 13 | -------------------------------------------------------------------------------- /src/workers/compute.worker.singleton.ts: -------------------------------------------------------------------------------- 1 | import { compute as computeBlocking } from './compute' 2 | 3 | export async function compute(a: number, b: number): Promise { 4 | return computeBlocking(a, b) 5 | } 6 | -------------------------------------------------------------------------------- /src/workers/compute.worker.ts: -------------------------------------------------------------------------------- 1 | import { compute as computeBlocking } from './compute' 2 | 3 | export async function compute(a: number, b: number): Promise { 4 | return computeBlocking(a, b) 5 | } 6 | -------------------------------------------------------------------------------- /tests/App.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import * as ReactDOM from 'react-dom' 3 | import * as TestUtils from 'react-dom/test-utils' 4 | import App from '../src/components/App' 5 | 6 | it('App is rendered', () => { 7 | // Render App in the document 8 | const appElement: App = TestUtils.renderIntoDocument() 9 | 10 | const appNode = ReactDOM.findDOMNode(appElement) 11 | 12 | // Verify text content 13 | expect(appNode.textContent).toEqual('Hello World!Foo to the barz') 14 | }) 15 | -------------------------------------------------------------------------------- /tests/__mocks__/fileMock.js: -------------------------------------------------------------------------------- 1 | module.exports = 'test-file-stub'; 2 | -------------------------------------------------------------------------------- /tests/__mocks__/shim.js: -------------------------------------------------------------------------------- 1 | global.requestAnimationFrame = (callback) => { 2 | setTimeout(callback, 0); 3 | }; 4 | -------------------------------------------------------------------------------- /tests/__mocks__/styleMock.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist/", 4 | "sourceMap": true, 5 | "noImplicitAny": true, 6 | "noImplicitReturns": false, 7 | "module": "es6", 8 | "target": "es5", 9 | "jsx": "react", 10 | "lib": ["es5", "es6", "dom"], 11 | "moduleResolution": "node", 12 | "esModuleInterop": true, 13 | "allowSyntheticDefaultImports": true, 14 | "allowJs": true 15 | }, 16 | "include": [ 17 | "./src/**/*" 18 | ], 19 | "awesomeTypescriptLoaderOptions": { 20 | "reportFiles": [ 21 | "./src/**/*" 22 | ] 23 | } 24 | } 25 | --------------------------------------------------------------------------------