├── .eslintrc.json ├── .github └── workflows │ └── release.yml ├── .gitignore ├── .prettierrc ├── .travis.yml ├── LICENSE ├── hero.svg ├── package.json ├── readme.md ├── release.config.js ├── rollup.config.js ├── src └── index.ts ├── tsconfig.json └── yarn.lock /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true, 5 | "node": true 6 | }, 7 | "extends": [ 8 | "prettier", 9 | "prettier/react", 10 | "prettier/@typescript-eslint", 11 | "plugin:prettier/recommended", 12 | "plugin:react-hooks/recommended", 13 | "plugin:import/errors", 14 | "plugin:import/warnings", 15 | "prettier", 16 | "prettier/react", 17 | "prettier/@typescript-eslint" 18 | ], 19 | "plugins": ["@typescript-eslint", "react", "react-hooks", "import", "jest", "prettier"], 20 | "parser": "@typescript-eslint/parser", 21 | "parserOptions": { 22 | "ecmaFeatures": { 23 | "jsx": true 24 | }, 25 | "ecmaVersion": 2018, 26 | "sourceType": "module", 27 | "rules": { 28 | "curly": ["warn", "multi-line", "consistent"], 29 | "no-console": "off", 30 | "no-empty-pattern": "warn", 31 | "no-duplicate-imports": "error", 32 | "import/no-unresolved": "off", 33 | "import/export": "error", 34 | // https://github.com/typescript-eslint/typescript-eslint/blob/master/docs/getting-started/linting/FAQ.md#eslint-plugin-import 35 | // We recommend you do not use the following import/* rules, as TypeScript provides the same checks as part of standard type checking: 36 | "import/named": "off", 37 | "import/namespace": "off", 38 | "import/default": "off", 39 | "no-unused-vars": ["warn", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }], 40 | "@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }], 41 | "@typescript-eslint/no-use-before-define": "off", 42 | "@typescript-eslint/no-empty-function": "off", 43 | "@typescript-eslint/no-empty-interface": "off", 44 | "@typescript-eslint/no-explicit-any": "off", 45 | "jest/consistent-test-it": ["error", { "fn": "it", "withinDescribe": "it" }] 46 | } 47 | }, 48 | "settings": { 49 | "react": { 50 | "version": "detect" 51 | }, 52 | "import/extensions": [".js", ".jsx", ".ts", ".tsx"], 53 | "import/parsers": { 54 | "@typescript-eslint/parser": [".js", ".jsx", ".ts", ".tsx"] 55 | }, 56 | "import/resolver": { 57 | "node": { 58 | "extensions": [".js", ".jsx", ".ts", ".tsx", ".json"], 59 | "paths": ["src"] 60 | }, 61 | "alias": { 62 | "extensions": [".js", ".jsx", ".ts", ".tsx", ".json"], 63 | "map": [["react-three-fiber", "./src/targets/web.tsx"]] 64 | } 65 | } 66 | }, 67 | "overrides": [ 68 | { 69 | "files": ["src"], 70 | "parserOptions": { 71 | "project": "./tsconfig.json" 72 | } 73 | } 74 | ] 75 | } 76 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: 3 | push: 4 | branches: 5 | - 'main' 6 | 7 | # Cancel any previous run (see: https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#concurrency) 8 | concurrency: 9 | group: ${{ github.workflow }}-${{ github.ref }} 10 | cancel-in-progress: true 11 | 12 | jobs: 13 | release-job: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v3 17 | - uses: actions/setup-node@v3 18 | with: 19 | cache: 'yarn' 20 | - id: main 21 | run: | 22 | yarn install 23 | yarn build 24 | yarn release 25 | env: 26 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 27 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # misc 2 | .DS_Store 3 | .env.local 4 | .env.development.local 5 | .env.test.local 6 | .env.production.local 7 | 8 | # Logs 9 | logs 10 | *.log 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | 15 | # Directory for instrumented libs generated by jscoverage/JSCover 16 | lib-cov 17 | 18 | # Coverage directory used by tools like istanbul 19 | coverage 20 | *.lcov 21 | 22 | # nyc test coverage 23 | .nyc_output 24 | 25 | # Compiled binary addons (https://nodejs.org/api/addons.html) 26 | build/ 27 | 28 | # Dependency directories 29 | node_modules/ 30 | jspm_packages/ 31 | 32 | # TypeScript cache 33 | *.tsbuildinfo 34 | 35 | # Optional eslint cache 36 | .eslintcache 37 | 38 | # Output of 'npm pack' 39 | *.tgz 40 | 41 | # Yarn Integrity file 42 | .yarn-integrity 43 | 44 | # pnpm lock 45 | 46 | pnpm-lock.yaml 47 | 48 | dist 49 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "trailingComma": "es5", 4 | "singleQuote": true, 5 | "tabWidth": 2, 6 | "printWidth": 120 7 | } 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - stable 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Paul Henschel 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 | -------------------------------------------------------------------------------- /hero.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "suspend-react", 3 | "version": "0.0.0-semantic-release", 4 | "description": "Integrate React Suspense into your apps", 5 | "main": "index.cjs.js", 6 | "module": "index.js", 7 | "types": "index.d.ts", 8 | "sideEffects": false, 9 | "keywords": [ 10 | "react", 11 | "suspense", 12 | "resource", 13 | "asset" 14 | ], 15 | "author": "Paul Henschel", 16 | "license": "MIT", 17 | "repository": "pmndrs/suspend-react", 18 | "homepage": "https://github.com/pmndrs/suspend-react#readme", 19 | "husky": { 20 | "hooks": { 21 | "pre-commit": "lint-staged" 22 | } 23 | }, 24 | "lint-staged": { 25 | "*.{js,jsx,ts,tsx}": [ 26 | "eslint --fix" 27 | ] 28 | }, 29 | "scripts": { 30 | "build": "rollup -c", 31 | "postbuild": "tsc --emitDeclarationOnly && npm run copy", 32 | "copy": "copyfiles package.json readme.md LICENSE dist && json -I -f dist/package.json -e \"this.private=false; this.devDependencies=undefined; this.optionalDependencies=undefined; this.scripts=undefined; this.husky=undefined; this.prettier=undefined; this.jest=undefined; this['lint-staged']=undefined;\"", 33 | "release": "semantic-release", 34 | "test": "echo no tests yet" 35 | }, 36 | "devDependencies": { 37 | "@babel/core": "7.16.0", 38 | "@babel/plugin-proposal-class-properties": "^7.16.0", 39 | "@babel/plugin-transform-modules-commonjs": "7.16.0", 40 | "@babel/plugin-transform-parameters": "7.16.0", 41 | "@babel/plugin-transform-runtime": "7.16.0", 42 | "@babel/plugin-transform-template-literals": "7.16.0", 43 | "@babel/preset-env": "7.16.0", 44 | "@babel/preset-react": "7.16.0", 45 | "@babel/preset-typescript": "^7.16.0", 46 | "@rollup/plugin-babel": "^5.3.0", 47 | "@rollup/plugin-node-resolve": "^13.0.6", 48 | "@types/jest": "^27.0.2", 49 | "@types/node": "^16.11.6", 50 | "@types/react": "^17.0.33", 51 | "@types/react-dom": "^17.0.10", 52 | "@types/react-test-renderer": "^17.0.1", 53 | "@typescript-eslint/eslint-plugin": "^5.3.0", 54 | "@typescript-eslint/parser": "^5.3.0", 55 | "copyfiles": "^2.4.1", 56 | "eslint": "^8.1.0", 57 | "eslint-config-prettier": "^8.3.0", 58 | "eslint-import-resolver-alias": "^1.1.2", 59 | "eslint-plugin-import": "^2.25.2", 60 | "eslint-plugin-jest": "^25.2.2", 61 | "eslint-plugin-prettier": "^4.0.0", 62 | "eslint-plugin-react": "^7.26.1", 63 | "eslint-plugin-react-hooks": "^4.2.0", 64 | "husky": "^7.0.4", 65 | "json": "^11.0.0", 66 | "lint-staged": "^11.2.6", 67 | "prettier": "^2.4.1", 68 | "react": "^17.0.1", 69 | "rollup": "^2.59.0", 70 | "rollup-plugin-size-snapshot": "^0.12.0", 71 | "rollup-plugin-terser": "^7.0.2", 72 | "semantic-release": "^21.0.5", 73 | "typescript": "^4.4.4" 74 | }, 75 | "peerDependencies": { 76 | "react": ">=17.0" 77 | }, 78 | "dependencies": {} 79 | } 80 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | [![Build Size](https://img.shields.io/bundlephobia/minzip/suspend-react@0.0.8?label=bundle%20size&style=flat&colorA=000000&colorB=000000)](https://bundlephobia.com/result?p=suspend-react) 2 | [![Version](https://img.shields.io/npm/v/suspend-react?style=flat&colorA=000000&colorB=000000)](https://www.npmjs.com/package/suspend-react) 3 | 4 |
5 | 6 |
7 |
8 | 9 | ```shell 10 | npm install suspend-react 11 | ``` 12 | 13 | This library integrates your async ops into React suspense. Pending- and error-states are handled at the parental level which frees the individual component from that burden and allows for better orchestration. Think of it as async/await for components. **Works in all React versions >= 16.6**. 14 | 15 | ```jsx 16 | import { Suspense } from 'react' 17 | import { suspend } from 'suspend-react' 18 | 19 | function Post({ id, version }) { 20 | const data = suspend(async () => { 21 | const res = await fetch(`https://hacker-news.firebaseio.com/${version}/item/${id}.json`) 22 | return res.json() 23 | }, [id, version]) 24 | return ( 25 |
26 | {data.title} by {data.by} 27 |
28 | ) 29 | } 30 | 31 | function App() { 32 | return ( 33 | loading...}> 34 | 35 | 36 | ) 37 | } 38 | ``` 39 | 40 | #### API 41 | 42 | ```tsx 43 | const suspend = , Fn extends (...keys: Keys) => Promise>( 44 | fn: Fn | Promise, 45 | keys?: Keys, 46 | config?: Config 47 | ) => Await> 48 | ``` 49 | 50 | ```tsx 51 | // Function that returns a promise 52 | const result = suspend((...keys) => anyPromise, keys, config) 53 | // async function 54 | const result = suspend(async (...keys) => { /* ... */ }, keys, config) 55 | // Promise with keys 56 | const result = suspend(anyPromise, keys, config) 57 | // Promise itself is the key 58 | const result = suspend(anyPromise) 59 | ``` 60 | 61 | `suspend` yields control back to React and the render-phase is aborted. It will resume once your promise resolves. For this to work you need to wrap it into a `` block, which requires you to set a fallback (can be `null`). 62 | 63 | The dependencies (the 2nd argument) act as cache-keys, use as many as you want. If an entry is already in cache, calling `suspend` with the same keys will return it _immediately_ without breaking the render-phase. Cache access is similar to useMemo but *across the component tree*. 64 | 65 | The 1st argument has to be a promise, or a function that returns a promise, or an asyn function. It receives the keys as arguments. `suspend` will return the resolved value, not a promise! This is guaranteed, *you do not have to check for validity*. Errors will bubble up to the nearest error-boundary. 66 | 67 | #### Config 68 | 69 | Both `suspend` and `preload` can _optionally_ receive a config object, 70 | 71 | ###### Keep-alive 72 | 73 | The `lifespan` prop allows you to invalidate items over time, it defaults to `0` (keep-alive forever). Every read refreshes the timer to ensure that used entries stay valid. 74 | 75 | ```jsx 76 | // Keep cached item alive for one minute without read 77 | suspend(fn, keys, { lifespan: 60000 }) 78 | ``` 79 | 80 | ###### Equality function 81 | 82 | The `equal` prop customizes per-key validation, it defaults to `(a, b) => a === b` (reference equality). 83 | 84 | ```jsx 85 | import equal from 'fast-deep-equal' 86 | 87 | // Validate keys deeply 88 | suspend(fn, keys, { equal }) 89 | ``` 90 | 91 | #### Preloading 92 | 93 | ```jsx 94 | import { preload } from 'suspend-react' 95 | 96 | async function fetchFromHN(id, version) { 97 | const res = await fetch(`https://hacker-news.firebaseio.com/${version}/item/${id}.json`) 98 | return res.json() 99 | } 100 | 101 | preload(fetchFromHN, [1000, 'v0']) 102 | ``` 103 | 104 | #### Cache busting 105 | 106 | ```jsx 107 | import { clear } from 'suspend-react' 108 | 109 | // Clear all cached entries 110 | clear() 111 | // Clear a specific entry 112 | clear([1000, 'v0']) 113 | ``` 114 | 115 | #### Peeking into entries outside of suspense 116 | 117 | ```jsx 118 | import { peek } from 'suspend-react' 119 | 120 | // This will either return the value (without suspense!) or undefined 121 | peek([1000, 'v0']) 122 | ``` 123 | 124 | #### Making cache-keys unique 125 | 126 | Since `suspend` operates on a global cache (for now, see [React 18](#react-18)), you might be wondering if keys could bleed, and yes they would. To establish cache-safety, create unique or semi-unique appendixes. 127 | 128 | ```diff 129 | - suspend(fn, [1000, 'v0']) 130 | + suspend(fn, [1000, 'v0', 'functionName/fetch']) 131 | ``` 132 | 133 | If you publish a library that suspends, consider symbols. 134 | 135 | ```jsx 136 | const fetchUUID = Symbol() 137 | 138 | export function Foo() { 139 | suspend(fn, [1000, 'v0', fetchUUID]) 140 | ``` 141 | 142 | #### Typescript 143 | 144 | Correct types will be inferred automatically. 145 | 146 | #### React 18 147 | 148 | Suspense, as is, has been a stable part of React since 16.6, but React will likely add some [interesting caching and cache busting APIs](https://github.com/reactwg/react-18/discussions/25) that could allow you to define cache boundaries declaratively. Expect these to be work for suspend-react once they come out. 149 | 150 | #### Demos 151 | 152 | Fetching posts from hacker-news: [codesandbox](https://codesandbox.io/s/use-asset-forked-yb62q) 153 | 154 | Infinite list: [codesandbox](https://codesandbox.io/s/use-asset-infinite-list-forked-cwvs7) 155 | -------------------------------------------------------------------------------- /release.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | branches: ['main'], 3 | plugins: [ 4 | // https://github.com/semantic-release/semantic-release/blob/master/docs/extending/plugins-list.md 5 | '@semantic-release/commit-analyzer', 6 | '@semantic-release/release-notes-generator', 7 | '@semantic-release/github', 8 | [ 9 | '@semantic-release/npm', 10 | { 11 | pkgRoot: './dist', 12 | }, 13 | ], 14 | ], 15 | } 16 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import babel from '@rollup/plugin-babel' 3 | import resolve from '@rollup/plugin-node-resolve' 4 | 5 | const root = process.platform === 'win32' ? path.resolve('/') : '/' 6 | const external = (id) => !id.startsWith('.') && !id.startsWith(root) 7 | const extensions = ['.js', '.jsx', '.ts', '.tsx', '.json'] 8 | 9 | const getBabelOptions = ({ useESModules }) => ({ 10 | babelrc: false, 11 | extensions, 12 | exclude: '**/node_modules/**', 13 | babelHelpers: 'runtime', 14 | presets: [ 15 | [ 16 | '@babel/preset-env', 17 | { 18 | include: [ 19 | '@babel/plugin-proposal-optional-chaining', 20 | '@babel/plugin-proposal-nullish-coalescing-operator', 21 | '@babel/plugin-proposal-numeric-separator', 22 | '@babel/plugin-proposal-logical-assignment-operators', 23 | ], 24 | bugfixes: true, 25 | loose: true, 26 | modules: false, 27 | targets: '> 1%, not dead, not ie 11, not op_mini all', 28 | }, 29 | ], 30 | '@babel/preset-react', 31 | '@babel/preset-typescript', 32 | ], 33 | plugins: [['@babel/transform-runtime', { regenerator: false, useESModules }]], 34 | }) 35 | 36 | export default [ 37 | { 38 | input: `./src/index.ts`, 39 | output: { file: `dist/index.js`, format: 'esm' }, 40 | external, 41 | plugins: [ 42 | babel(getBabelOptions({ useESModules: true })), 43 | resolve({ extensions }), 44 | ], 45 | }, 46 | { 47 | input: `./src/index.ts`, 48 | output: { file: `dist/index.cjs.js`, format: 'cjs' }, 49 | external, 50 | plugins: [babel(getBabelOptions({ useESModules: false })), resolve({ extensions })], 51 | }, 52 | ] 53 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | type Tuple = [T] | T[] 2 | type Await = T extends Promise ? V : never 3 | type Config = { lifespan?: number; equal?: (a: any, b: any) => boolean } 4 | type Cache> = { 5 | promise: Promise 6 | keys: Keys 7 | equal?: (a: any, b: any) => boolean 8 | error?: any 9 | response?: unknown 10 | timeout?: ReturnType 11 | remove: () => void 12 | } 13 | 14 | const isPromise = (promise: any): promise is Promise => 15 | typeof promise === 'object' && typeof (promise as Promise).then === 'function' 16 | 17 | const globalCache: Cache>[] = [] 18 | 19 | function shallowEqualArrays( 20 | arrA: any[], 21 | arrB: any[], 22 | equal: (a: any, b: any) => boolean = (a: any, b: any) => a === b 23 | ) { 24 | if (arrA === arrB) return true 25 | if (!arrA || !arrB) return false 26 | const len = arrA.length 27 | if (arrB.length !== len) return false 28 | for (let i = 0; i < len; i++) if (!equal(arrA[i], arrB[i])) return false 29 | return true 30 | } 31 | 32 | function query, Fn extends (...keys: Keys) => Promise>( 33 | fn: Fn | Promise, 34 | keys: Keys = null as unknown as Keys, 35 | preload = false, 36 | config: Partial = {} 37 | ) { 38 | 39 | // If no keys were given, the function is the key 40 | if (keys === null) keys = [fn] as unknown as Keys 41 | 42 | for (const entry of globalCache) { 43 | // Find a match 44 | if (shallowEqualArrays(keys, entry.keys, entry.equal)) { 45 | // If we're pre-loading and the element is present, just return 46 | if (preload) return undefined as unknown as Await> 47 | // If an error occurred, throw 48 | if (Object.prototype.hasOwnProperty.call(entry, 'error')) throw entry.error 49 | // If a response was successful, return 50 | if (Object.prototype.hasOwnProperty.call(entry, 'response')) { 51 | if (config.lifespan && config.lifespan > 0) { 52 | if (entry.timeout) clearTimeout(entry.timeout) 53 | entry.timeout = setTimeout(entry.remove, config.lifespan) 54 | } 55 | return entry.response as Await> 56 | } 57 | // If the promise is still unresolved, throw 58 | if (!preload) throw entry.promise 59 | } 60 | } 61 | 62 | // The request is new or has changed. 63 | const entry: Cache = { 64 | keys, 65 | equal: config.equal, 66 | remove: () => { 67 | const index = globalCache.indexOf(entry) 68 | if (index !== -1) globalCache.splice(index, 1) 69 | }, 70 | promise: 71 | // Execute the promise 72 | (isPromise(fn) ? fn : fn(...keys)) 73 | // When it resolves, store its value 74 | .then((response) => { 75 | entry.response = response 76 | // Remove the entry in time if a lifespan was given 77 | if (config.lifespan && config.lifespan > 0) { 78 | entry.timeout = setTimeout(entry.remove, config.lifespan) 79 | } 80 | }) 81 | // Store caught errors, they will be thrown in the render-phase to bubble into an error-bound 82 | .catch((error) => (entry.error = error)), 83 | } 84 | // Register the entry 85 | globalCache.push(entry) 86 | // And throw the promise, this yields control back to React 87 | if (!preload) throw entry.promise 88 | return undefined as unknown as Await> 89 | } 90 | 91 | const suspend = , Fn extends (...keys: Keys) => Promise>( 92 | fn: Fn | Promise, 93 | keys?: Keys, 94 | config?: Config 95 | ) => query(fn, keys, false, config) 96 | 97 | const preload = , Fn extends (...keys: Keys) => Promise>( 98 | fn: Fn | Promise, 99 | keys?: Keys, 100 | config?: Config 101 | ) => void query(fn, keys, true, config) 102 | 103 | const peek = >(keys: Keys) => 104 | globalCache.find((entry) => shallowEqualArrays(keys, entry.keys, entry.equal))?.response 105 | 106 | const clear = >(keys?: Keys) => { 107 | if (keys === undefined || keys.length === 0) globalCache.splice(0, globalCache.length) 108 | else { 109 | const entry = globalCache.find((entry) => shallowEqualArrays(keys, entry.keys, entry.equal)) 110 | if (entry) entry.remove() 111 | } 112 | } 113 | 114 | export { suspend, clear, preload, peek } 115 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "esnext", 4 | "target": "es2018", 5 | "allowSyntheticDefaultImports": true, 6 | "jsx": "react", 7 | "strict": true, 8 | "preserveSymlinks": true, 9 | "moduleResolution": "Node", 10 | "esModuleInterop": true, 11 | "declaration": true, 12 | "declarationDir": "dist", 13 | "skipLibCheck": true, 14 | "removeComments": false, 15 | "baseUrl": "." 16 | }, 17 | "include": ["src"] 18 | } 19 | --------------------------------------------------------------------------------