├── .npmignore ├── example ├── src │ ├── fetcher.ts │ ├── utils.css │ ├── main.js │ ├── index.html │ ├── Switch.svelte │ └── App.svelte ├── static │ └── favicon.png ├── .prettierrc ├── tailwind.config.js ├── svelte.config.js ├── .prettierignore ├── tsconfig.json ├── .eslintrc.js ├── .gitignore ├── LICENSE ├── postcss.config.js ├── package.json ├── rollup.config.js └── README.md ├── src ├── index.ts ├── browser_state.ts └── fetcher.ts ├── dist ├── index.d.ts ├── index.js ├── index.js.map ├── index.d.ts.map ├── browser_state.d.ts.map ├── browser_state.d.ts ├── fetcher.d.ts.map ├── browser_state.js.map ├── browser_state.js ├── fetcher.d.ts ├── fetcher.js.map └── fetcher.js ├── .gitignore ├── package.json ├── LICENSE ├── README.md └── tsconfig.json /.npmignore: -------------------------------------------------------------------------------- 1 | example 2 | -------------------------------------------------------------------------------- /example/src/fetcher.ts: -------------------------------------------------------------------------------- 1 | import { fetcher } from 'swr-xstate'; 2 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './browser_state'; 2 | export * from './fetcher'; 3 | -------------------------------------------------------------------------------- /example/static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dimfeld/swr-xstate/HEAD/example/static/favicon.png -------------------------------------------------------------------------------- /dist/index.d.ts: -------------------------------------------------------------------------------- 1 | export * from './browser_state'; 2 | export * from './fetcher'; 3 | //# sourceMappingURL=index.d.ts.map -------------------------------------------------------------------------------- /dist/index.js: -------------------------------------------------------------------------------- 1 | export * from './browser_state'; 2 | export * from './fetcher'; 3 | //# sourceMappingURL=index.js.map -------------------------------------------------------------------------------- /example/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "es5", 4 | "arrowParens": "always" 5 | } 6 | -------------------------------------------------------------------------------- /dist/index.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,iBAAiB,CAAC;AAChC,cAAc,WAAW,CAAC"} -------------------------------------------------------------------------------- /dist/index.d.ts.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,iBAAiB,CAAC;AAChC,cAAc,WAAW,CAAC"} -------------------------------------------------------------------------------- /example/src/utils.css: -------------------------------------------------------------------------------- 1 | /* Import Tailwind as Global Utils */ 2 | @import 'tailwindcss/base'; 3 | @import 'tailwindcss/components'; 4 | @import 'tailwindcss/utilities'; 5 | -------------------------------------------------------------------------------- /example/src/main.js: -------------------------------------------------------------------------------- 1 | import './utils.css'; 2 | import App from './App.svelte'; 3 | 4 | const app = new App({ 5 | target: document.body, 6 | }); 7 | 8 | export default app; 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /node_modules/ 3 | yarn-error.log 4 | /cypress/screenshots/ 5 | .vscode 6 | .env 7 | yarn.lock 8 | package-lock.json 9 | dist/tsconfig.tsbuildinfo 10 | -------------------------------------------------------------------------------- /example/tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | purge: false, 3 | theme: { 4 | extend: {}, 5 | }, 6 | variants: {}, 7 | plugins: [require('@tailwindcss/ui')], 8 | }; 9 | -------------------------------------------------------------------------------- /example/svelte.config.js: -------------------------------------------------------------------------------- 1 | const sveltePreprocess = require('svelte-preprocess'); 2 | 3 | const dev = process.env.NODE_ENV === 'development'; 4 | 5 | module.exports = { 6 | preprocess: sveltePreprocess({ 7 | postcss: require('./postcss.config'), 8 | aliases: [['ts', 'typescript']], 9 | }), 10 | }; 11 | -------------------------------------------------------------------------------- /example/.prettierignore: -------------------------------------------------------------------------------- 1 | .git/* 2 | .DS_Store 3 | 4 | license 5 | yarn.lock 6 | .travis.yml 7 | 8 | .yarnclean 9 | .eslintignore 10 | .prettierignore 11 | .npmignore 12 | .gitignore 13 | .dockerignore 14 | 15 | dist 16 | build 17 | packages/*/lib/app 18 | consoles/*/lib/app 19 | 20 | *.ico 21 | *.html 22 | *.log 23 | *.svg 24 | *.map 25 | *.png 26 | *.snap 27 | *.ttf 28 | *.sh 29 | *.txt -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "sourceMap": true, 4 | "module": "ESNext", 5 | "moduleResolution": "node", 6 | "target": "es2020", 7 | "noEmit": true, 8 | "lib": ["dom", "es5", "ES2015", "es2016", "es2017", "es2018", "ES2019"] 9 | }, 10 | "paths": { 11 | "^/*": ["src/*"] 12 | }, 13 | "include": ["src"], 14 | "compileOnSave": false 15 | } 16 | -------------------------------------------------------------------------------- /dist/browser_state.d.ts.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"browser_state.d.ts","sourceRoot":"","sources":["../src/browser_state.ts"],"names":[],"mappings":"AACA,wBAAgB,iBAAiB,IAAI,OAAO,CAS3C;AAED,wBAAgB,QAAQ,IAAI,OAAO,CAMlC;AAED,wBAAgB,iBAAiB,IAAI,OAAO,CAE3C;AAED,MAAM,WAAW,YAAY;IAC3B,MAAM,EAAE,OAAO,CAAC;IAChB,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,EAAE,OAAO,CAAC;CAClB;AAED,wBAAgB,eAAe,IAAK,YAAY,CAM/C;AAuCD,oBAAY,YAAY,GAAG,MAAM,IAAI,CAAC;AAEtC,wBAAgB,SAAS,CAAC,EAAE,EAAG,CAAC,KAAK,EAAG,YAAY,KAAK,GAAG,GAAI,YAAY,CAe3E"} -------------------------------------------------------------------------------- /example/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | > 3 | 4 | 5 | 6 | 7 | <%= title %> 8 | 9 | 10 | <%= links %> 11 | 12 | 13 | 14 | 15 | <%= scripts %> 16 | 17 | 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "swr-xstate", 3 | "version": "1.0.2", 4 | "description": "SWR Fetcher implemented using XState", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "scripts": { 8 | "build": "rimraf dist && tsc", 9 | "dev": "tsc --watch" 10 | }, 11 | "author": "Daniel Imfeld ", 12 | "license": "MIT", 13 | "dependencies": { 14 | "xstate": "^4.11.0" 15 | }, 16 | "devDependencies": { 17 | "rimraf": "^3.0.2", 18 | "typescript": "^3" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /dist/browser_state.d.ts: -------------------------------------------------------------------------------- 1 | export declare function isDocumentVisible(): boolean; 2 | export declare function isOnline(): boolean; 3 | export declare function isDocumentFocused(): boolean; 4 | export interface BrowserState { 5 | online: boolean; 6 | visible: boolean; 7 | focused: boolean; 8 | } 9 | export declare function getBrowserState(): BrowserState; 10 | export declare type Unsubscriber = () => void; 11 | export declare function subscribe(cb: (state: BrowserState) => any): Unsubscriber; 12 | //# sourceMappingURL=browser_state.d.ts.map -------------------------------------------------------------------------------- /example/.eslintrc.js: -------------------------------------------------------------------------------- 1 | const jsRules = { 2 | 'no-unused-vars': ['warn', { args: 'none' }], 3 | 'no-return-await': 'warn', 4 | 'no-use-before-define': 'error', 5 | 'no-mixed-spaces-and-tabs': 'error', 6 | 'no-trailing-spaces': 'warn', 7 | }; 8 | 9 | module.exports = { 10 | env: { 11 | browser: true, 12 | es6: true, 13 | node: true, 14 | }, 15 | parser: '@typescript-eslint/parser', 16 | parserOptions: { 17 | project: 'tsconfig.json', 18 | ecmaVersion: 2019, 19 | sourceType: 'module', 20 | }, 21 | overrides: [ 22 | { 23 | files: ['**/*.svelte'], 24 | parser: 'espree', 25 | processor: 'svelte3/svelte3', 26 | extends: ['eslint:recommended'], 27 | rules: jsRules, 28 | }, 29 | { 30 | files: ['**/*.js'], 31 | extends: ['eslint:recommended'], 32 | rules: jsRules, 33 | }, 34 | ], 35 | plugins: ['@typescript-eslint/eslint-plugin', 'svelte3'], 36 | extends: [ 37 | 'plugin:@typescript-eslint/eslint-recommended', 38 | 'prettier/@typescript-eslint', 39 | ], 40 | rules: {}, 41 | }; 42 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | public 2 | public/bundle.* 3 | public/utils.* 4 | build 5 | ### Node ### 6 | # Logs 7 | logs 8 | *.log 9 | npm-debug.log* 10 | 11 | # Runtime data 12 | pids 13 | *.pid 14 | *.seed 15 | *.pid.lock 16 | 17 | # node-waf configuration 18 | .lock-wscript 19 | 20 | # Compiled binary addons (http://nodejs.org/api/addons.html) 21 | build/Release 22 | 23 | # Dependency directories 24 | node_modules 25 | jspm_packages 26 | 27 | # Optional npm cache directory 28 | .npm 29 | 30 | # Optional eslint cache 31 | .eslintcache 32 | 33 | # Optional REPL history 34 | .node_repl_history 35 | 36 | # Output of 'npm pack' 37 | *.tgz 38 | 39 | ### Git ### 40 | *.orig 41 | 42 | ### macOS ### 43 | .DS_Store 44 | .AppleDouble 45 | .LSOverride 46 | 47 | # Thumbnails 48 | ._* 49 | # Files that might appear in the root of a volume 50 | .DocumentRevisions-V100 51 | .fseventsd 52 | .Spotlight-V100 53 | .TemporaryItems 54 | .Trashes 55 | .VolumeIcon.icns 56 | .com.apple.timemachine.donotpresent 57 | # Directories potentially created on remote AFP share 58 | .AppleDB 59 | .AppleDesktop 60 | Network Trash Folder 61 | Temporary Items 62 | .apdisk 63 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Daniel Imfeld 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 | -------------------------------------------------------------------------------- /example/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Daniel Imfeld 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 13 | all 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 NON-INFRINGEMENT. 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /example/src/Switch.svelte: -------------------------------------------------------------------------------- 1 | 20 | 21 | 33 | 40 | -------------------------------------------------------------------------------- /example/postcss.config.js: -------------------------------------------------------------------------------- 1 | const production = process.env.NODE_ENV !== 'development'; 2 | const purgecss = require('@fullhuman/postcss-purgecss'); 3 | const cssnano = require('cssnano'); 4 | 5 | module.exports = { 6 | plugins: [ 7 | require('postcss-import')(), 8 | require('tailwindcss'), 9 | require('autoprefixer'), 10 | production && 11 | purgecss({ 12 | content: ['./src/**/*.html', './static/**/*.html', './src/**/*.svelte'], 13 | whitelistPatterns: [/^svelte-/], 14 | defaultExtractor: (content) => { 15 | const regExp = new RegExp(/[A-Za-z0-9-_:/.]+/g); 16 | 17 | const matchedTokens = []; 18 | 19 | let match = regExp.exec(content); 20 | // To make sure that you do not lose any tailwind classes used in class directive. 21 | // https://github.com/tailwindcss/discuss/issues/254#issuecomment-517918397 22 | while (match) { 23 | if (match[0].startsWith('class:')) { 24 | matchedTokens.push(match[0].substring(6)); 25 | } else { 26 | matchedTokens.push(match[0]); 27 | } 28 | 29 | match = regExp.exec(content); 30 | } 31 | 32 | return matchedTokens; 33 | }, 34 | }), 35 | production && cssnano(), 36 | ].filter(Boolean), 37 | }; 38 | -------------------------------------------------------------------------------- /dist/fetcher.d.ts.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"fetcher.d.ts","sourceRoot":"","sources":["../src/fetcher.ts"],"names":[],"mappings":"AACA,OAAO,EAA8B,KAAK,EAAE,cAAc,EAAE,MAAM,QAAQ,CAAC;AAE3E,eAAO,MAAM,UAAU,eAAuB,CAAC;AAE/C,MAAM,WAAW,WAAW,CAAC,CAAC;IAC5B,IAAI,CAAC,EAAE,CAAC,CAAC;IACT,KAAK,CAAC,EAAE,KAAK,CAAC;IACd,iCAAiC;IACjC,SAAS,EAAE,MAAM,CAAC;IAClB,wDAAwD;IACxD,KAAK,CAAC,EAAG,OAAO,CAAC;CAClB;AAED,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,KAAK,CAAC,OAAO,EAAE,cAAc,EAAE,GAAG,EAAE,GAAG,CAAC,CAAC;IAChD,KAAK,EAAE,cAAc,CAAC;CACvB;AAED,MAAM,WAAW,WAAW,CAAC,CAAC;IAC5B,IAAI,EAAE,CAAC,CAAC;IACR,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,kBAAkB,CACjC,CAAC;IAED;;OAEG;IACH,GAAG,EAAE,MAAM,CAAC;IAEZ,6EAA6E;IAC7E,IAAI,CAAC,EAAE,MAAM,CAAC;IAEd,qDAAqD;IACrD,WAAW,CAAC,EAAE,CAAC,GAAG,EAAC,MAAM,KAAK,OAAO,CAAC,WAAW,CAAC,CAAC,CAAC,GAAC,IAAI,CAAC,CAAC;IAE3D,4DAA4D;IAC5D,OAAO,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,CAAC,GAAC,MAAM,CAAC,CAAC;IAC5C,qDAAqD;IACrD,OAAO,EAAE,CAAC,MAAM,EAAG,WAAW,CAAC,CAAC,CAAC,KAAK,GAAG,CAAC;IAE1C,iFAAiF;IACjF,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAE3B,8GAA8G;IAC9G,UAAU,CAAC,EAAG,MAAM,CAAC;IAErB,yIAAyI;IACzI,gBAAgB,CAAC,EAAG,OAAO,CAAC;IAE5B;;;OAGG;IACH,cAAc,CAAC,EAAG,OAAO,CAAC;IAE1B,wLAAwL;IACxL,KAAK,CAAC,EAAE,CAAC,GAAG,EAAG,YAAY,KAAK,GAAG,CAAC;CACrC;AAED,MAAM,WAAW,WAAW;IAC1B,8GAA8G;IAC9G,UAAU,EAAE,CAAC,OAAO,EAAG,OAAO,KAAK,IAAI,CAAC;IACxC;4DACwD;IACxD,YAAY,EAAE,CAAC,SAAS,EAAE,OAAO,KAAK,IAAI,CAAC;IAC3C,mGAAmG;IACnG,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB;AAED,MAAM,WAAW,OAAO;IACtB,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,MAAM,CAAC;IAChB,aAAa,EAAE,OAAO,CAAC;IACvB,YAAY,EAAE,OAAO,CAAC;IACtB,cAAc,EAAE,OAAO,CAAC;IACxB,SAAS,EAAE,OAAO,CAAC;CACpB;AAED,wBAAgB,OAAO,CACrB,CAAC,EAED,EACE,IAAI,EACJ,GAAG,EACH,iBAAkC,EAAE,iBAAiB;AACrD,UAAsB,EAAE,mBAAmB;AAC3C,OAAO,EACP,OAAO,EACP,gBAAgB,EAChB,cAAc,EACd,WAAW,EACX,KAAK,GACN,EAAE,kBAAkB,CAAC,CAAC,CAAC,GACtB,WAAW,CAiOd"} -------------------------------------------------------------------------------- /dist/browser_state.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"browser_state.js","sourceRoot":"","sources":["../src/browser_state.ts"],"names":[],"mappings":"AAAA,uIAAuI;AACvI,MAAM,UAAU,iBAAiB;IAC/B,IACE,OAAO,QAAQ,KAAK,WAAW;QAC/B,OAAO,QAAQ,CAAC,eAAe,KAAK,WAAW,EAC/C;QACA,OAAO,QAAQ,CAAC,eAAe,KAAK,QAAQ,CAAC;KAC9C;IACD,iCAAiC;IACjC,OAAO,IAAI,CAAC;AACd,CAAC;AAED,MAAM,UAAU,QAAQ;IACtB,IAAI,OAAO,SAAS,CAAC,MAAM,KAAK,WAAW,EAAE;QAC3C,OAAO,SAAS,CAAC,MAAM,CAAC;KACzB;IACD,8DAA8D;IAC9D,OAAO,IAAI,CAAC;AACd,CAAC;AAED,MAAM,UAAU,iBAAiB;IAC/B,OAAO,QAAQ,CAAC,QAAQ,EAAE,CAAC;AAC7B,CAAC;AAQD,MAAM,UAAU,eAAe;IAC7B,OAAO;QACL,MAAM,EAAE,QAAQ,EAAE;QAClB,OAAO,EAAE,iBAAiB,EAAE;QAC5B,OAAO,EAAE,iBAAiB,EAAE;KAC7B,CAAC;AACJ,CAAC;AAED,MAAM,SAAS,GAAG,EAAE,CAAC;AAErB,SAAS,OAAO;IACd,IAAI,KAAK,GAAG,eAAe,EAAE,CAAC;IAC9B,IAAI,KAAK,CAAC;IAEV,KAAI,IAAI,QAAQ,IAAI,SAAS,EAAE;QAC7B,IAAI;YACF,QAAQ,CAAC,KAAK,CAAC,CAAC;SACjB;QAAC,OAAM,CAAC,EAAE;YACT,2EAA2E;YAC3E,qBAAqB;YACrB,KAAK,GAAG,CAAC,CAAC;SACX;KACF;IAED,IAAG,KAAK,EAAE;QACR,qEAAqE;QACrE,MAAM,KAAK,CAAC;KACb;AACH,CAAC;AAED,SAAS,WAAW,CAAC,EAAE;IACrB,IAAI,KAAK,GAAG,SAAS,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC;IAClC,IAAG,KAAK,KAAK,CAAC,CAAC,EAAE;QACf,SAAS,CAAC,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC;KAC5B;IAED,IAAG,CAAC,SAAS,CAAC,MAAM,EAAE;QACpB,MAAM,CAAC,mBAAmB,CAAC,kBAAkB,EAAE,OAAO,CAAC,CAAC;QACxD,MAAM,CAAC,mBAAmB,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;QAC7C,MAAM,CAAC,mBAAmB,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;QAC5C,MAAM,CAAC,mBAAmB,CAAC,QAAQ,EAAE,OAAO,CAAC,CAAC;QAC9C,MAAM,CAAC,mBAAmB,CAAC,SAAS,EAAE,OAAO,CAAC,CAAC;KAChD;AACH,CAAC;AAID,MAAM,UAAU,SAAS,CAAC,EAAkC;IAC1D,IAAG,CAAC,SAAS,CAAC,MAAM,EAAE;QACpB,MAAM,CAAC,gBAAgB,CAAC,kBAAkB,EAAE,OAAO,CAAC,CAAC;QACrD,MAAM,CAAC,gBAAgB,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;QAC1C,MAAM,CAAC,gBAAgB,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;QACzC,MAAM,CAAC,gBAAgB,CAAC,QAAQ,EAAE,OAAO,CAAC,CAAC;QAC3C,MAAM,CAAC,gBAAgB,CAAC,SAAS,EAAE,OAAO,CAAC,CAAC;KAC7C;IAED,SAAS,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IAEnB,uDAAuD;IACvD,EAAE,CAAC,eAAe,EAAE,CAAC,CAAC;IAEtB,OAAO,GAAG,EAAE,CAAC,WAAW,CAAC,EAAE,CAAC,CAAC;AAC/B,CAAC"} -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "swr-xstate-example", 3 | "author": "Daniel Imfeld", 4 | "license": "MIT", 5 | "version": "0.0.2", 6 | "scripts": { 7 | "clean": "rimraf public", 8 | "rollup": "rollup -c", 9 | "rollup:watch": "rollup -c -w", 10 | "build": "NODE_ENV=production run-s clean rollup", 11 | "dev": "NODE_ENV=development npm-run-all clean --parallel start:dev rollup:watch", 12 | "start": "sirv public --single -H 0.0.0.0", 13 | "start:dev": "NODE_ENV=development sirv public --single --dev -H 0.0.0.0" 14 | }, 15 | "dependencies": { 16 | "sirv-cli": "^0.4.4", 17 | "swr-xstate": "^1.0.1" 18 | }, 19 | "devDependencies": { 20 | "@babel/core": "^7.9.0", 21 | "@babel/plugin-proposal-class-properties": "^7.8.3", 22 | "@babel/plugin-proposal-nullish-coalescing-operator": "^7.8.3", 23 | "@babel/plugin-proposal-object-rest-spread": "^7.8.3", 24 | "@babel/plugin-proposal-optional-chaining": "^7.8.3", 25 | "@babel/plugin-syntax-dynamic-import": "^7.8.3", 26 | "@babel/plugin-transform-runtime": "^7.9.0", 27 | "@babel/preset-env": "^7.9.0", 28 | "@babel/preset-typescript": "^7.9.0", 29 | "@babel/runtime": "^7.9.0", 30 | "@fullhuman/postcss-purgecss": "^1.3.0", 31 | "@rollup/plugin-babel": "^5.0.3", 32 | "@rollup/plugin-commonjs": "^13.0.0", 33 | "@rollup/plugin-html": "^0.2.0", 34 | "@rollup/plugin-node-resolve": "^8.1.0", 35 | "@rollup/plugin-replace": "^2.3.3", 36 | "@tailwindcss/ui": "^0.1.3", 37 | "@typescript-eslint/eslint-plugin": "^2.26.0", 38 | "@typescript-eslint/parser": "^2.26.0", 39 | "autoprefixer": "^9.6.1", 40 | "babel-eslint": "^10.0.3", 41 | "cssnano": "^4.1.10", 42 | "eslint": "^6.8.0", 43 | "eslint-config-prettier": "^6.10.1", 44 | "eslint-plugin-svelte3": "^2.7.3", 45 | "lodash": "^4.17.21", 46 | "npm-run-all": "^4.1.5", 47 | "postcss": "^7.0.18", 48 | "postcss-cli": "^6.1.3", 49 | "postcss-import": "^12.0.1", 50 | "prettier": "^2", 51 | "prettier-plugin-svelte": "^1.1.0", 52 | "rollup": "^2", 53 | "rollup-plugin-copy": "^3.3.0", 54 | "rollup-plugin-livereload": "^1.0.0", 55 | "rollup-plugin-postcss": "^2.0.3", 56 | "rollup-plugin-svelte": "^5.2.2", 57 | "rollup-plugin-terser": "^6.1.0", 58 | "svelte": "^3.24", 59 | "svelte-preprocess": "^4", 60 | "tailwindcss": "^1.4.0", 61 | "typescript": "^3.9" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /dist/browser_state.js: -------------------------------------------------------------------------------- 1 | // The browser state functions here are mostly adapted from the related functions in https://github.com/vercel/swr/tree/master/src/libs 2 | export function isDocumentVisible() { 3 | if (typeof document !== 'undefined' && 4 | typeof document.visibilityState !== 'undefined') { 5 | return document.visibilityState !== 'hidden'; 6 | } 7 | // Otherwise assume it's visible. 8 | return true; 9 | } 10 | export function isOnline() { 11 | if (typeof navigator.onLine !== 'undefined') { 12 | return navigator.onLine; 13 | } 14 | // Assume it's online if onLine doesn't exist for some reason. 15 | return true; 16 | } 17 | export function isDocumentFocused() { 18 | return document.hasFocus(); 19 | } 20 | export function getBrowserState() { 21 | return { 22 | online: isOnline(), 23 | visible: isDocumentVisible(), 24 | focused: isDocumentFocused(), 25 | }; 26 | } 27 | const listeners = []; 28 | function refresh() { 29 | let state = getBrowserState(); 30 | let error; 31 | for (let listener of listeners) { 32 | try { 33 | listener(state); 34 | } 35 | catch (e) { 36 | // Catch errors and store them away to ensure that we call all the handlers 37 | // even if one fails. 38 | error = e; 39 | } 40 | } 41 | if (error) { 42 | // Throw the error so that any global error handlers will pick it up. 43 | throw error; 44 | } 45 | } 46 | function unsubscribe(cb) { 47 | let index = listeners.indexOf(cb); 48 | if (index !== -1) { 49 | listeners.splice(index, 1); 50 | } 51 | if (!listeners.length) { 52 | window.removeEventListener('visibilitychange', refresh); 53 | window.removeEventListener('focus', refresh); 54 | window.removeEventListener('blur', refresh); 55 | window.removeEventListener('online', refresh); 56 | window.removeEventListener('offline', refresh); 57 | } 58 | } 59 | export function subscribe(cb) { 60 | if (!listeners.length) { 61 | window.addEventListener('visibilitychange', refresh); 62 | window.addEventListener('focus', refresh); 63 | window.addEventListener('blur', refresh); 64 | window.addEventListener('online', refresh); 65 | window.addEventListener('offline', refresh); 66 | } 67 | listeners.push(cb); 68 | // Call the callback right away with the current state. 69 | cb(getBrowserState()); 70 | return () => unsubscribe(cb); 71 | } 72 | //# sourceMappingURL=browser_state.js.map -------------------------------------------------------------------------------- /src/browser_state.ts: -------------------------------------------------------------------------------- 1 | // The browser state functions here are mostly adapted from the related functions in https://github.com/vercel/swr/tree/master/src/libs 2 | export function isDocumentVisible(): boolean { 3 | if ( 4 | typeof document !== 'undefined' && 5 | typeof document.visibilityState !== 'undefined' 6 | ) { 7 | return document.visibilityState !== 'hidden'; 8 | } 9 | // Otherwise assume it's visible. 10 | return true; 11 | } 12 | 13 | export function isOnline(): boolean { 14 | if (typeof navigator.onLine !== 'undefined') { 15 | return navigator.onLine; 16 | } 17 | // Assume it's online if onLine doesn't exist for some reason. 18 | return true; 19 | } 20 | 21 | export function isDocumentFocused(): boolean { 22 | return document.hasFocus(); 23 | } 24 | 25 | export interface BrowserState { 26 | online: boolean; 27 | visible: boolean; 28 | focused: boolean; 29 | } 30 | 31 | export function getBrowserState() : BrowserState { 32 | return { 33 | online: isOnline(), 34 | visible: isDocumentVisible(), 35 | focused: isDocumentFocused(), 36 | }; 37 | } 38 | 39 | const listeners = []; 40 | 41 | function refresh() { 42 | let state = getBrowserState(); 43 | let error; 44 | 45 | for(let listener of listeners) { 46 | try { 47 | listener(state); 48 | } catch(e) { 49 | // Catch errors and store them away to ensure that we call all the handlers 50 | // even if one fails. 51 | error = e; 52 | } 53 | } 54 | 55 | if(error) { 56 | // Throw the error so that any global error handlers will pick it up. 57 | throw error; 58 | } 59 | } 60 | 61 | function unsubscribe(cb) { 62 | let index = listeners.indexOf(cb); 63 | if(index !== -1) { 64 | listeners.splice(index, 1); 65 | } 66 | 67 | if(!listeners.length) { 68 | window.removeEventListener('visibilitychange', refresh); 69 | window.removeEventListener('focus', refresh); 70 | window.removeEventListener('blur', refresh); 71 | window.removeEventListener('online', refresh); 72 | window.removeEventListener('offline', refresh); 73 | } 74 | } 75 | 76 | export type Unsubscriber = () => void; 77 | 78 | export function subscribe(cb : (state : BrowserState) => any) : Unsubscriber { 79 | if(!listeners.length) { 80 | window.addEventListener('visibilitychange', refresh); 81 | window.addEventListener('focus', refresh); 82 | window.addEventListener('blur', refresh); 83 | window.addEventListener('online', refresh); 84 | window.addEventListener('offline', refresh); 85 | } 86 | 87 | listeners.push(cb); 88 | 89 | // Call the callback right away with the current state. 90 | cb(getBrowserState()); 91 | 92 | return () => unsubscribe(cb); 93 | } 94 | -------------------------------------------------------------------------------- /dist/fetcher.d.ts: -------------------------------------------------------------------------------- 1 | import { State, AnyEventObject } from 'xstate'; 2 | export declare const UNMODIFIED: unique symbol; 3 | export interface FetchResult { 4 | data?: T; 5 | error?: Error; 6 | /** When this data was fetched */ 7 | timestamp: number; 8 | /** True if this is the stale data. Absent otherwise. */ 9 | stale?: boolean; 10 | } 11 | export interface DebugMessage { 12 | id: string; 13 | state: State; 14 | event: AnyEventObject; 15 | } 16 | export interface InitialData { 17 | data: T; 18 | timestamp?: number; 19 | } 20 | export interface AutoFetcherOptions { 21 | /** A string that can uniquely identify the resource to be fetched. This is 22 | * passed as an argument to the `fetch` and `initialData` functions. 23 | */ 24 | key: string; 25 | /** A name for this fetcher. Will default to the vaue of `key` if not set. */ 26 | name?: string; 27 | /** A function that should fetch the "stale" data. */ 28 | initialData?: (key: string) => Promise | null>; 29 | /** `fetcher` is called periodically to retrieve new data */ 30 | fetcher: (key: string) => Promise; 31 | /** `receive` is called when new data has arrived. */ 32 | receive: (result: FetchResult) => any; 33 | /** Number of milliseconds between refresh attempts, unless refresh is forced. */ 34 | autoRefreshPeriod?: number; 35 | /** Maximum number of milliseconds to wait between refresh attempts in case of error. Defaults to 1 minute. */ 36 | maxBackoff?: number; 37 | /** True if the state machine should permit refreshes by default. False if it should wait for `setPerrmitted(true)`. Defaults to true. */ 38 | initialPermitted?: boolean; 39 | /** True if the state machine should be enabled by default. False if it should wait for `setEnabled(true)` 40 | * with `true` as the data before it starts refreshing. Defaults to true. 41 | * Typically this is used to disable updates if nothing in the application is actually listening for changes. 42 | */ 43 | initialEnabled?: boolean; 44 | /** Given an object, this function should print out debug information. This can be `console.log` if you want, or something like the `debug` module. Called on every state transition. */ 45 | debug?: (msg: DebugMessage) => any; 46 | } 47 | export interface AutoFetcher { 48 | /** Set if fetching is enabled. It might be disabled if you know that nothing is using this data right now. */ 49 | setEnabled: (enabled: boolean) => void; 50 | /** Set if fetching is permitted. Fetching might not be permitted if the user is not logged in or lacks 51 | * proper permissions for this endpoint, for example. */ 52 | setPermitted: (permitted: boolean) => void; 53 | /** Force a refresh. This will not do anything if fetching has been disabled via `setPermitted`. */ 54 | refresh: () => void; 55 | destroy: () => void; 56 | } 57 | export interface Context { 58 | lastRefresh: number; 59 | retries: number; 60 | reportedError: boolean; 61 | storeEnabled: boolean; 62 | browserEnabled: boolean; 63 | permitted: boolean; 64 | } 65 | export declare function fetcher({ name, key, autoRefreshPeriod, // default 1 hour 66 | maxBackoff, // default 1 minute 67 | fetcher, receive, initialPermitted, initialEnabled, initialData, debug, }: AutoFetcherOptions): AutoFetcher; 68 | //# sourceMappingURL=fetcher.d.ts.map -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This is an implementation of a stale-while-revalidate data fetcher implemented with the [XState](https://xstate.js.org) library. It implements many of the features found in other popular SWR libraries, such as periodic updates and disabling the fetcher when the browser is not focused. 2 | 3 | I wrote about the design and implementation on [my website](https://imfeld.dev/writing/swr_with_xstate), and there's also a simple [example site](https://swr-xstate.imfeld.dev). 4 | 5 | ```typescript 6 | /** The fetcher function can return this if it tried to fetch and found the data was unchanged. */ 7 | export const UNMODIFIED = Symbol('unmodified'); 8 | 9 | export interface FetchResult { 10 | data?: T; 11 | error?: Error; 12 | /** When this data was fetched */ 13 | timestamp: number; 14 | /** True if this is the stale data. Absent otherwise. */ 15 | stale? : boolean; 16 | } 17 | 18 | export interface DebugMessage { 19 | id: string; 20 | state: State; 21 | event: AnyEventObject; 22 | } 23 | 24 | export interface InitialData { 25 | data: T; 26 | timestamp?: number; 27 | } 28 | 29 | export interface AutoFetcherOptions< 30 | T 31 | > { 32 | /** A string that can uniquely identify the resource to be fetched. This is 33 | * passed as an argument to the `fetch` and `initialData` functions. 34 | */ 35 | key: string; 36 | 37 | /** A name for this fetcher. Will default to the vaue of `key` if not set. */ 38 | name?: string; 39 | 40 | /** A function that should fetch the "stale" data. Called when the fetcher is created. */ 41 | initialData?: (key:string) => Promise|null>; 42 | 43 | /** `fetcher` is called periodically to retrieve new data */ 44 | fetcher: (key: string) => Promise, 45 | /** `receive` is called when new data has arrived. */ 46 | receive: (result : FetchResult) => any; 47 | 48 | /** Number of milliseconds between refresh attempts, unless refresh is forced. */ 49 | autoRefreshPeriod?: number; 50 | 51 | /** Maximum number of milliseconds to wait between refresh attempts in case of error. Defaults to 1 minute. */ 52 | maxBackoff? : number; 53 | 54 | /** True if the state machine should permit refreshes by default. False if it should wait for `setPerrmitted(true)`. Defaults to true. */ 55 | initialPermitted? : boolean; 56 | 57 | /** True if the state machine should be enabled by default. False if it should wait for `setEnabled(true)` 58 | * with `true` as the data before it starts refreshing. Defaults to true. 59 | * Typically this is used to disable updates if nothing in the application is actually listening for changes. 60 | */ 61 | initialEnabled? : boolean; 62 | 63 | /** Given an object, this function should print out debug information. This can be `console.log` if you want, or something like the `debug` module. Called on every state transition. */ 64 | debug?: (msg : DebugMessage) => any; 65 | } 66 | 67 | export interface AutoFetcher { 68 | /** Set if fetching is enabled. It might be disabled if you know that nothing is using this data right now. */ 69 | setEnabled: (enabled : boolean) => void; 70 | /** Set if fetching is permitted. Fetching might not be permitted if the user is not logged in or lacks 71 | * proper permissions for this endpoint, for example. */ 72 | setPermitted: (permitted: boolean) => void; 73 | /** Force a refresh. This will not do anything if fetching has been disabled via `setPermitted`. */ 74 | refresh: () => void; 75 | destroy: () => void; 76 | } 77 | 78 | /** This function creates a fetcher */ 79 | export function fetcher(options : AutoFetcherOptions) : AutoFetcher; 80 | ``` 81 | 82 | -------------------------------------------------------------------------------- /example/rollup.config.js: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import { template } from 'lodash'; 3 | import svelte from 'rollup-plugin-svelte'; 4 | import resolve from '@rollup/plugin-node-resolve'; 5 | import replace from '@rollup/plugin-replace'; 6 | import commonjs from '@rollup/plugin-commonjs'; 7 | import livereload from 'rollup-plugin-livereload'; 8 | import { terser } from 'rollup-plugin-terser'; 9 | import postcss from 'rollup-plugin-postcss'; 10 | import babel from '@rollup/plugin-babel'; 11 | import html, { makeHtmlAttributes } from '@rollup/plugin-html'; 12 | import copy from 'rollup-plugin-copy'; 13 | 14 | const production = process.env.NODE_ENV !== 'development'; 15 | 16 | const babelConfig = { 17 | extensions: ['.js', '.mjs', '.html', '.svelte', '.ts'], 18 | exclude: ['node_modules/@babel/**', 'static/**', 'build/**', 'public/**'], 19 | presets: [ 20 | ['@babel/preset-env', { targets: { esmodules: true } }], 21 | '@babel/preset-typescript', 22 | ], 23 | plugins: [ 24 | '@babel/plugin-syntax-dynamic-import', 25 | '@babel/proposal-class-properties', 26 | '@babel/plugin-proposal-object-rest-spread', 27 | '@babel/plugin-proposal-nullish-coalescing-operator', 28 | '@babel/plugin-proposal-optional-chaining', 29 | ], 30 | }; 31 | 32 | export default { 33 | input: 'src/main.js', 34 | output: { 35 | dir: 'public', 36 | entryFileNames: '[name].[hash].js', 37 | chunkFileNames: '[name].[hash].js', 38 | assetFileNames: '[name].[hash][extname]', 39 | sourcemap: production ? true : 'inline', 40 | format: 'esm', 41 | }, 42 | plugins: [ 43 | svelte({ 44 | preprocess: require('./svelte.config').preprocess, 45 | // enable run-time checks when not in production 46 | dev: !production, 47 | // extract any component CSS out into 48 | // a separate file — better for performance 49 | // css: css => { 50 | // css.write('public/bundle.css'); 51 | // }, 52 | // Instead, emit CSS as a file for processing through rollup 53 | emitCss: true, 54 | }), 55 | postcss({ 56 | extract: true, 57 | }), 58 | 59 | resolve({ 60 | mainFields: ['module', 'browser', 'main'], 61 | extensions: ['.mjs', '.js', '.json', '.ts', '.svelte'], 62 | dedupe: (importee) => 63 | importee === 'svelte' || importee.startsWith('svelte/'), 64 | }), 65 | copy({ 66 | targets: [{ src: 'static/**/*', dest: 'public/' }], 67 | }), 68 | commonjs(), 69 | babel(babelConfig), 70 | 71 | replace({ 72 | 'process.env.NODE_ENV': JSON.stringify( 73 | process.env.NODE_ENV || 'development' 74 | ), 75 | }), 76 | 77 | // Watch the `public` directory and refresh the 78 | // browser on changes when not in production 79 | !production && livereload('public'), 80 | 81 | // If we're building for production (npm run build 82 | // instead of npm run dev), minify 83 | production && terser(), 84 | 85 | html({ 86 | title: 'SWR-Xstate Example', 87 | template: ({ attributes, files, publicPath, title }) => { 88 | let templateFile = fs.readFileSync('src/index.html'); 89 | 90 | // This is adapted from the default template function in the HTML plugin. 91 | const scripts = (files.js || []) 92 | .map(({ fileName }) => { 93 | const attrs = makeHtmlAttributes(attributes.script); 94 | return ``; 95 | }) 96 | .join('\n'); 97 | 98 | const links = (files.css || []) 99 | .map(({ fileName }) => { 100 | const attrs = makeHtmlAttributes(attributes.link); 101 | return ``; 102 | }) 103 | .join('\n'); 104 | 105 | let exec = template(templateFile.toString()); 106 | return exec({ 107 | attributes, 108 | title, 109 | scripts, 110 | links, 111 | htmlAttributes: makeHtmlAttributes(attributes.html), 112 | }); 113 | }, 114 | }), 115 | ], 116 | watch: { 117 | clearScreen: false, 118 | }, 119 | }; 120 | -------------------------------------------------------------------------------- /example/src/App.svelte: -------------------------------------------------------------------------------- 1 | 67 | 68 | 73 | 74 |
75 |
SWR Xstate
76 |
77 | Built by 78 | Daniel Imfeld. 79 | See it on 80 | Github. 81 |
82 | 83 |
84 | This is a very simple example of automatic periodic fetching that gets a new 85 | random Unsplash image every five seconds. You can use the checkboxes to 86 | alter the behavior of the state machine. 87 |
88 | 89 |
90 |
91 | 95 | 99 | 103 | 104 | 114 | 115 | 131 |
132 | 133 |
134 | 135 |
136 | Fetch Result: 137 | {errorText || imageSrc} 138 | {#if receivedStale} 139 | (stale) 140 | {/if} 141 |
142 |
143 | image result 144 |
145 |
146 |
147 | Current State: 148 | 149 | {fetcherState && fetcherState.toStrings()} 150 | 151 |
152 |
Last Refresh: {new Date(latestTimestamp).toTimeString()}
153 |
Store Enabled: {fetcherState.context.storeEnabled}
154 |
Browser Active: {fetcherState.context.browserEnabled}
155 |
Fetching Permitted: {fetcherState.context.permitted}
156 |
157 |
158 |
159 |
160 | -------------------------------------------------------------------------------- /dist/fetcher.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"fetcher.js","sourceRoot":"","sources":["../src/fetcher.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,kBAAkB,MAAM,iBAAiB,CAAC;AACtD,OAAO,EAAE,SAAS,EAAE,MAAM,EAAE,OAAO,EAAyB,MAAM,QAAQ,CAAC;AAE3E,MAAM,CAAC,MAAM,UAAU,GAAG,MAAM,CAAC,YAAY,CAAC,CAAC;AAgF/C,MAAM,UAAU,OAAO,CAGrB,EACE,IAAI,EACJ,GAAG,EACH,iBAAiB,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,EAAE,iBAAiB;AACrD,UAAU,GAAG,EAAE,GAAG,IAAI,EAAE,mBAAmB;AAC3C,OAAO,EACP,OAAO,EACP,gBAAgB,EAChB,cAAc,EACd,WAAW,EACX,KAAK,GACiB;IAExB,IAAI,GAAG,IAAI,IAAI,GAAG,CAAC;IACnB,MAAM,EAAE,GAAG,eAAe,IAAI,EAAE,CAAC;IAEjC,MAAM,UAAU,GAAG,OAAO,CACxB;QACE,EAAE;QACF,OAAO,EAAE,YAAY;QACrB,EAAE,EAAE;YACF,eAAe,EAAE,EAAE,MAAM,EAAE,YAAY,EAAE,OAAO,EAAE,oBAAoB,EAAE;YACxE,aAAa,EAAE,EAAE,MAAM,EAAE,YAAY,EAAE,OAAO,EAAE,iBAAiB,EAAE;YACnE,eAAe,EAAE;gBACf,MAAM,EAAE,YAAY;gBACpB,OAAO,EAAE,sBAAsB;aAChC;YACD,YAAY,EAAE;gBACZ,OAAO,EAAE,oBAAoB;aAC9B;SACF;QACD,MAAM,EAAE;YACN,2GAA2G;YAC3G,YAAY,EAAE;gBACZ,KAAK,EAAE,CAAC,WAAW,EAAE,kBAAkB,CAAC;aACzC;YACD,yFAAyF;YACzF,QAAQ,EAAE;gBACR,EAAE,EAAE;oBACF,aAAa,EAAE;wBACb,MAAM,EAAE,YAAY;wBACpB,IAAI,EAAE,sBAAsB;qBAC7B;iBACF;aACF;YACD,UAAU,EAAE;gBACV,MAAM,EAAE;oBACN,EAAE,IAAI,EAAE,0BAA0B,EAAE,MAAM,EAAE,cAAc,EAAE;oBAC5D,EAAE,IAAI,EAAE,YAAY,EAAE,MAAM,EAAE,mBAAmB,EAAE;oBACnD,EAAE,MAAM,EAAE,UAAU,EAAE;iBACvB;aACF;YACD,iBAAiB,EAAE;gBACjB,EAAE,EAAE;oBACF,aAAa,EAAE,YAAY;iBAC5B;gBACD,KAAK,EAAE;oBACL,gBAAgB,EAAE,YAAY;iBAC/B;aACF;YACD,UAAU,EAAE;gBACV,EAAE,EAAE;oBACF,gEAAgE;oBAChE,uCAAuC;oBACvC,eAAe,EAAE,EAAE,MAAM,EAAE,SAAS,EAAE,OAAO,EAAE,oBAAoB,EAAE;oBACrE,aAAa,EAAE,EAAE,MAAM,EAAE,SAAS,EAAE,OAAO,EAAE,iBAAiB,EAAE;oBAChE,eAAe,EAAE;wBACf,MAAM,EAAE,SAAS;wBACjB,OAAO,EAAE,sBAAsB;qBAChC;iBACF;gBACD,MAAM,EAAE;oBACN,EAAE,EAAE,SAAS;oBACb,GAAG,EAAE,SAAS;oBACd,MAAM,EAAE;wBACN,MAAM,EAAE,YAAY;wBACpB,OAAO,EAAE,aAAa;qBACvB;oBACD,OAAO,EAAE;wBACP,MAAM,EAAE,cAAc;wBACtB,OAAO,EAAE,aAAa;qBACvB;iBACF;aACF;YACD,YAAY,EAAE;gBACZ,KAAK,EAAE,gBAAgB;gBACvB,KAAK,EAAE;oBACL,iBAAiB,EAAE,YAAY;iBAChC;aACF;SACF;KACF,EACD;QACE,MAAM,EAAE;YACN,iBAAiB,EAAE,CAAC,OAAO,EAAE,KAAK,EAAE,EAAE;gBACpC,MAAM,SAAS,GAAG,GAAG,CAAC;gBACtB,MAAM,KAAK,GAAG,SAAS,GAAG,CAAC,IAAI,IAAI,CAAC,GAAG,CAAC,OAAO,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC;gBAC7D,OAAO,IAAI,CAAC,GAAG,CAAC,KAAK,EAAE,UAAU,CAAC,CAAC;YACrC,CAAC;YACD,gBAAgB,EAAE,CAAC,OAAO,EAAE,EAAE;gBAC5B,IAAI,gBAAgB,GAAG,IAAI,CAAC,GAAG,EAAE,GAAG,OAAO,CAAC,WAAW,CAAC;gBACxD,IAAI,SAAS,GAAG,iBAAiB,GAAG,gBAAgB,CAAC;gBACrD,OAAO,IAAI,CAAC,GAAG,CAAC,SAAS,EAAE,CAAC,CAAC,CAAC;YAChC,CAAC;SACF;QACD,QAAQ,EAAE;YACR,OAAO,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,GAAG,CAAC;SAC5B;QACD,OAAO,EAAE;YACP,gBAAgB,EAAE,MAAM,CAAC;gBACvB,WAAW,EAAE,GAAG,EAAE,CAAC,CAAC;aACrB,CAAC;YACF,eAAe,EAAE,MAAM,CAAC;gBACtB,SAAS,EAAE,CAAC,GAAG,EAAE,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,IAAI;aACtC,CAAC;YACF,kBAAkB,EAAE,MAAM,CAAC;gBACzB,YAAY,EAAE,CAAC,GAAG,EAAE,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,IAAI;aACzC,CAAC;YACF,oBAAoB,EAAE,MAAM,CAAC;gBAC3B,cAAc,EAAE,CAAC,GAAG,EAAE,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,IAAI;aAC3C,CAAC;YACF,kBAAkB,EAAE,MAAM,CAAC,CAAC,OAAgB,EAAE,EAAC,IAAI,EAAC,EAAE,EAAE;gBACtD,IAAG,OAAO,CAAC,WAAW,EAAE;oBACtB,8DAA8D;oBAC9D,OAAO,EAAE,CAAC;iBACX;gBAED,IAAI,SAAS,GAAG,IAAI,CAAC,SAAS,IAAI,CAAC,CAAC;gBACpC,OAAO,CAAC;oBACN,IAAI,EAAE,IAAI,CAAC,IAAI;oBACf,KAAK,EAAE,IAAI;oBACX,SAAS;iBACV,CAAC,CAAC;gBAEH,OAAO;oBACL,qEAAqE;oBACrE,WAAW,EAAE,SAAS;iBACvB,CAAC;YACJ,CAAC,CAAC;YACF,cAAc,EAAE,MAAM,CAAC,EAAE,OAAO,EAAE,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,OAAO,GAAG,CAAC,EAAE,CAAC;YACrE,WAAW,EAAE,MAAM,CAAC,CAAC,OAAgB,EAAE,KAAK,EAAE,EAAE;gBAC9C,IAAI,WAAW,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;gBAC7B,IAAI,OAAO,GAAG;oBACZ,WAAW;oBACX,OAAO,EAAE,CAAC;oBACV,aAAa,EAAE,KAAK;iBACrB,CAAC;gBAEF,IAAG,KAAK,CAAC,IAAI,KAAK,UAAU,IAAI,OAAO,CAAC,SAAS,EAAE;oBACjD,OAAO,CAAC,EAAE,IAAI,EAAE,KAAK,CAAC,IAAI,EAAE,SAAS,EAAE,WAAW,EAAE,CAAC,CAAC;iBACvD;gBAED,OAAO,OAAO,CAAC;YACjB,CAAC,CAAC;YACF,WAAW,EAAE,MAAM,CAAC,CAAC,OAAgB,EAAE,KAAK,EAAE,EAAE;gBAC9C,mFAAmF;gBACnF,uBAAuB;gBACvB,IACE,CAAC,OAAO,CAAC,aAAa;oBACtB,kBAAkB,CAAC,QAAQ,EAAE,EAC7B;oBACA,OAAO,CAAC,EAAE,KAAK,EAAE,KAAK,CAAC,IAAI,EAAE,SAAS,EAAE,IAAI,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC;iBACvD;gBAED,OAAO;oBACL,aAAa,EAAE,IAAI;iBACpB,CAAC;YACJ,CAAC,CAAC;YACF,SAAS,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,CAAC,GAAG,EAAE,EAAE,CAAC;SAChE;QACD,MAAM,EAAE;YACN,wBAAwB,EAAE,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC,GAAG,CAAC,SAAS;YACjD,oBAAoB,EAAE,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,CAAC,SAAS;YAC5C,UAAU,EAAE,CAAC,GAAG,EAAE,EAAE;gBAClB,IAAI,CAAC,GAAG,CAAC,YAAY,IAAI,CAAC,GAAG,CAAC,SAAS,EAAE;oBACvC,OAAO,KAAK,CAAC;iBACd;gBAED,IAAI,CAAC,GAAG,CAAC,WAAW,EAAE;oBACpB,6CAA6C;oBAC7C,OAAO,IAAI,CAAC;iBACb;gBAED,uDAAuD;gBACvD,OAAO,GAAG,CAAC,cAAc,CAAC;YAC5B,CAAC;SACF;KACF,EACD;QACE,WAAW,EAAE,CAAC;QACd,OAAO,EAAE,CAAC;QACV,aAAa,EAAE,KAAK;QACpB,cAAc,EAAE,IAAI;QACpB,YAAY,EAAE,cAAc,KAAK,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI;QACrD,SAAS,EAAE,gBAAgB,KAAK,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI;KACrD,CACF,CAAC;IAEF,IAAI,OAAO,GAAG,SAAS,CAAC,UAAU,CAAC,CAAC,KAAK,EAAE,CAAC;IAE5C,IAAG,KAAK,EAAE;QACR,OAAO,CAAC,YAAY,CAAC,CAAC,KAAK,EAAE,KAAK,EAAE,EAAE;YACpC,KAAK,CAAC,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC,CAAC;QAC9B,CAAC,CAAC,CAAC;KACJ;IAED,IAAI,iBAAiB,GAAG,kBAAkB,CAAC,SAAS,CAAC,CAAC,KAAK,EAAE,EAAE;QAC7D,IAAI,OAAO,GAAG,KAAK,CAAC,OAAO,IAAI,KAAK,CAAC,MAAM,IAAI,KAAK,CAAC,OAAO,CAAC;QAC7D,OAAO,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,iBAAiB,EAAE,IAAI,EAAE,OAAO,EAAE,CAAC,CAAC;IAC3D,CAAC,CAAC,CAAC;IAEH,KAAK,UAAU,YAAY;QACzB,IAAI,IAAI,GAAG,MAAM,WAAW,CAAC,GAAG,CAAC,CAAC;QAClC,IAAI,CAAC,GAAG,IAAI,EAAE,IAAI,CAAC;QACnB,IAAG,CAAC,KAAK,IAAI,IAAI,CAAC,KAAK,SAAS,EAAE;YAChC,OAAO,CAAC,IAAI,CAAC,cAAc,EAAE,EAAC,IAAI,EAAC,CAAC,CAAC;SACtC;IACH,CAAC;IAED,IAAG,WAAW,EAAE;QACd,YAAY,EAAE,CAAC;KAChB;IAED,OAAO;QACL;0BACkB;QAClB,UAAU,EAAE,CAAC,OAAiB,EAAE,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,iBAAiB,EAAE,IAAI,EAAE,OAAO,EAAE,CAAC;QAC3F;gEACwD;QACxD,YAAY,EAAE,CAAC,SAAkB,EAAE,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,eAAe,EAAE,IAAI,EAAE,SAAS,EAAE,CAAC;QAC9F,mGAAmG;QACnG,OAAO,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC,eAAe,CAAC;QAC5C,OAAO,EAAE,GAAG,EAAE;YACZ,iBAAiB,EAAE,CAAC;YACpB,OAAO,CAAC,IAAI,EAAE,CAAC;QACjB,CAAC;KACF,CAAC;AACJ,CAAC"} -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src/**/*.ts"], 3 | "compilerOptions": { 4 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 5 | 6 | /* Basic Options */ 7 | "incremental": true, /* Enable incremental compilation */ 8 | "target": "ES2020", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ 9 | "module": "ESNext", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ 10 | "lib": ["dom", "ES2019"], /* Specify library files to be included in the compilation. */ 11 | // "allowJs": true, /* Allow javascript files to be compiled. */ 12 | // "checkJs": true, /* Report errors in .js files. */ 13 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 14 | "declaration": true, /* Generates corresponding '.d.ts' file. */ 15 | "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 16 | "sourceMap": true, /* Generates corresponding '.map' file. */ 17 | // "outFile": "./", /* Concatenate and emit output to single file. */ 18 | "outDir": "dist", /* Redirect output structure to the directory. */ 19 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 20 | // "composite": true, /* Enable project compilation */ 21 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 22 | // "removeComments": true, /* Do not emit comments to output. */ 23 | // "noEmit": true, /* Do not emit outputs. */ 24 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 25 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 26 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 27 | 28 | /* Strict Type-Checking Options */ 29 | // "strict": true, /* Enable all strict type-checking options. */ 30 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 31 | // "strictNullChecks": true, /* Enable strict null checks. */ 32 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 33 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 34 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 35 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 36 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 37 | 38 | /* Additional Checks */ 39 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 40 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 41 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 42 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 43 | 44 | /* Module Resolution Options */ 45 | "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 46 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 47 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 48 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 49 | // "typeRoots": [], /* List of folders to include type definitions from. */ 50 | // "types": [], /* Type declaration files to be included in compilation. */ 51 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 52 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 53 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 54 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 55 | 56 | /* Source Map Options */ 57 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 58 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 59 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 60 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 61 | 62 | /* Experimental Options */ 63 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 64 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 65 | 66 | /* Advanced Options */ 67 | "skipLibCheck": true, /* Skip type checking of declaration files. */ 68 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /dist/fetcher.js: -------------------------------------------------------------------------------- 1 | import * as browserStateModule from './browser_state'; 2 | import { interpret, assign, Machine } from 'xstate'; 3 | export const UNMODIFIED = Symbol('unmodified'); 4 | export function fetcher({ name, key, autoRefreshPeriod = 60 * 60 * 1000, // default 1 hour 5 | maxBackoff = 60 * 1000, // default 1 minute 6 | fetcher, receive, initialPermitted, initialEnabled, initialData, debug, }) { 7 | name = name || key; 8 | const id = `autofetcher-${name}`; 9 | const machineDef = Machine({ 10 | id, 11 | initial: 'maybeStart', 12 | on: { 13 | FETCHER_ENABLED: { target: 'maybeStart', actions: 'updateStoreEnabled' }, 14 | SET_PERMITTED: { target: 'maybeStart', actions: 'updatePermitted' }, 15 | BROWSER_ENABLED: { 16 | target: 'maybeStart', 17 | actions: 'updateBrowserEnabled', 18 | }, 19 | INITIAL_DATA: { 20 | actions: 'receiveInitialData', 21 | }, 22 | }, 23 | states: { 24 | // Not permitted to refresh, so ignore everything except the global events that might permit us to refresh. 25 | notPermitted: { 26 | entry: ['clearData', 'clearLastRefresh'], 27 | }, 28 | // Store is disabled, but still permitted to refresh so we honor the FORCE_REFRESH event. 29 | disabled: { 30 | on: { 31 | FORCE_REFRESH: { 32 | target: 'refreshing', 33 | cond: 'permitted_to_refresh', 34 | }, 35 | }, 36 | }, 37 | maybeStart: { 38 | always: [ 39 | { cond: 'not_permitted_to_refresh', target: 'notPermitted' }, 40 | { cond: 'can_enable', target: 'waitingForRefresh' }, 41 | { target: 'disabled' }, 42 | ], 43 | }, 44 | waitingForRefresh: { 45 | on: { 46 | FORCE_REFRESH: 'refreshing', 47 | }, 48 | after: { 49 | nextRefreshDelay: 'refreshing', 50 | }, 51 | }, 52 | refreshing: { 53 | on: { 54 | // Ignore the events while we're refreshing but still update the 55 | // context so we know where to go next. 56 | FETCHER_ENABLED: { target: undefined, actions: 'updateStoreEnabled' }, 57 | SET_PERMITTED: { target: undefined, actions: 'updatePermitted' }, 58 | BROWSER_ENABLED: { 59 | target: undefined, 60 | actions: 'updateBrowserEnabled', 61 | }, 62 | }, 63 | invoke: { 64 | id: 'refresh', 65 | src: 'refresh', 66 | onDone: { 67 | target: 'maybeStart', 68 | actions: 'refreshDone', 69 | }, 70 | onError: { 71 | target: 'errorBackoff', 72 | actions: 'reportError', 73 | }, 74 | }, 75 | }, 76 | errorBackoff: { 77 | entry: 'incrementRetry', 78 | after: { 79 | errorBackoffDelay: 'refreshing', 80 | }, 81 | }, 82 | }, 83 | }, { 84 | delays: { 85 | errorBackoffDelay: (context, event) => { 86 | const baseDelay = 200; 87 | const delay = baseDelay * 2 ** Math.min(context.retries, 20); 88 | return Math.min(delay, maxBackoff); 89 | }, 90 | nextRefreshDelay: (context) => { 91 | let timeSinceRefresh = Date.now() - context.lastRefresh; 92 | let remaining = autoRefreshPeriod - timeSinceRefresh; 93 | return Math.max(remaining, 0); 94 | }, 95 | }, 96 | services: { 97 | refresh: () => fetcher(key), 98 | }, 99 | actions: { 100 | clearLastRefresh: assign({ 101 | lastRefresh: () => 0, 102 | }), 103 | updatePermitted: assign({ 104 | permitted: (ctx, event) => event.data, 105 | }), 106 | updateStoreEnabled: assign({ 107 | storeEnabled: (ctx, event) => event.data, 108 | }), 109 | updateBrowserEnabled: assign({ 110 | browserEnabled: (ctx, event) => event.data, 111 | }), 112 | receiveInitialData: assign((context, { data }) => { 113 | if (context.lastRefresh) { 114 | // We already got some new data, so don't send the stale data. 115 | return {}; 116 | } 117 | let timestamp = data.timestamp || 0; 118 | receive({ 119 | data: data.data, 120 | stale: true, 121 | timestamp, 122 | }); 123 | return { 124 | // If the initial data included a timestamp, put it into lastRefresh. 125 | lastRefresh: timestamp, 126 | }; 127 | }), 128 | incrementRetry: assign({ retries: (context) => context.retries + 1 }), 129 | refreshDone: assign((context, event) => { 130 | let lastRefresh = Date.now(); 131 | let updated = { 132 | lastRefresh, 133 | retries: 0, 134 | reportedError: false, 135 | }; 136 | if (event.data !== UNMODIFIED && context.permitted) { 137 | receive({ data: event.data, timestamp: lastRefresh }); 138 | } 139 | return updated; 140 | }), 141 | reportError: assign((context, event) => { 142 | // Ignore the error if it happened because the browser went offline while fetching. 143 | // Otherwise report it. 144 | if (!context.reportedError && 145 | browserStateModule.isOnline()) { 146 | receive({ error: event.data, timestamp: Date.now() }); 147 | } 148 | return { 149 | reportedError: true, 150 | }; 151 | }), 152 | clearData: () => receive({ data: null, timestamp: Date.now() }), 153 | }, 154 | guards: { 155 | not_permitted_to_refresh: (ctx) => !ctx.permitted, 156 | permitted_to_refresh: (ctx) => ctx.permitted, 157 | can_enable: (ctx) => { 158 | if (!ctx.storeEnabled || !ctx.permitted) { 159 | return false; 160 | } 161 | if (!ctx.lastRefresh) { 162 | // Refresh if we haven't loaded any data yet. 163 | return true; 164 | } 165 | // Finally, we can enable if the browser tab is active. 166 | return ctx.browserEnabled; 167 | }, 168 | }, 169 | }, { 170 | lastRefresh: 0, 171 | retries: 0, 172 | reportedError: false, 173 | browserEnabled: true, 174 | storeEnabled: initialEnabled === false ? false : true, 175 | permitted: initialPermitted === false ? false : true, 176 | }); 177 | let machine = interpret(machineDef).start(); 178 | if (debug) { 179 | machine.onTransition((state, event) => { 180 | debug({ id, event, state }); 181 | }); 182 | } 183 | let browserStateUnsub = browserStateModule.subscribe((state) => { 184 | let enabled = state.focused && state.online && state.visible; 185 | machine.send({ type: 'BROWSER_ENABLED', data: enabled }); 186 | }); 187 | async function fetchInitial() { 188 | let data = await initialData(key); 189 | let d = data?.data; 190 | if (d !== null && d !== undefined) { 191 | machine.send('INITIAL_DATA', { data }); 192 | } 193 | } 194 | if (initialData) { 195 | fetchInitial(); 196 | } 197 | return { 198 | /** Enable or disable the fetcher. This is usually linked to whether there is anything that actually cares about this 199 | * data or not. */ 200 | setEnabled: (enabled) => machine.send({ type: 'FETCHER_ENABLED', data: enabled }), 201 | /** Set if fetching is permitted. Fetching might not be permitted if the user is not logged in or lacks 202 | * proper permissions for this endpoint, for example. */ 203 | setPermitted: (permitted) => machine.send({ type: 'SET_PERMITTED', data: permitted }), 204 | /** Force a refresh. This will not do anything if fetching has been disabled via `setPermitted`. */ 205 | refresh: () => machine.send('FORCE_REFRESH'), 206 | destroy: () => { 207 | browserStateUnsub(); 208 | machine.stop(); 209 | }, 210 | }; 211 | } 212 | //# sourceMappingURL=fetcher.js.map -------------------------------------------------------------------------------- /src/fetcher.ts: -------------------------------------------------------------------------------- 1 | import * as browserStateModule from './browser_state'; 2 | import { interpret, assign, Machine, State, AnyEventObject } from 'xstate'; 3 | 4 | export const UNMODIFIED = Symbol('unmodified'); 5 | 6 | export interface FetchResult { 7 | data?: T; 8 | error?: Error; 9 | /** When this data was fetched */ 10 | timestamp: number; 11 | /** True if this is the stale data. Absent otherwise. */ 12 | stale? : boolean; 13 | } 14 | 15 | export interface DebugMessage { 16 | id: string; 17 | state: State; 18 | event: AnyEventObject; 19 | } 20 | 21 | export interface InitialData { 22 | data: T; 23 | timestamp?: number; 24 | } 25 | 26 | export interface AutoFetcherOptions< 27 | T 28 | > { 29 | /** A string that can uniquely identify the resource to be fetched. This is 30 | * passed as an argument to the `fetch` and `initialData` functions. 31 | */ 32 | key: string; 33 | 34 | /** A name for this fetcher. Will default to the vaue of `key` if not set. */ 35 | name?: string; 36 | 37 | /** A function that should fetch the "stale" data. */ 38 | initialData?: (key:string) => Promise|null>; 39 | 40 | /** `fetcher` is called periodically to retrieve new data */ 41 | fetcher: (key: string) => Promise, 42 | /** `receive` is called when new data has arrived. */ 43 | receive: (result : FetchResult) => any; 44 | 45 | /** Number of milliseconds between refresh attempts, unless refresh is forced. */ 46 | autoRefreshPeriod?: number; 47 | 48 | /** Maximum number of milliseconds to wait between refresh attempts in case of error. Defaults to 1 minute. */ 49 | maxBackoff? : number; 50 | 51 | /** True if the state machine should permit refreshes by default. False if it should wait for `setPerrmitted(true)`. Defaults to true. */ 52 | initialPermitted? : boolean; 53 | 54 | /** True if the state machine should be enabled by default. False if it should wait for `setEnabled(true)` 55 | * with `true` as the data before it starts refreshing. Defaults to true. 56 | * Typically this is used to disable updates if nothing in the application is actually listening for changes. 57 | */ 58 | initialEnabled? : boolean; 59 | 60 | /** Given an object, this function should print out debug information. This can be `console.log` if you want, or something like the `debug` module. Called on every state transition. */ 61 | debug?: (msg : DebugMessage) => any; 62 | } 63 | 64 | export interface AutoFetcher { 65 | /** Set if fetching is enabled. It might be disabled if you know that nothing is using this data right now. */ 66 | setEnabled: (enabled : boolean) => void; 67 | /** Set if fetching is permitted. Fetching might not be permitted if the user is not logged in or lacks 68 | * proper permissions for this endpoint, for example. */ 69 | setPermitted: (permitted: boolean) => void; 70 | /** Force a refresh. This will not do anything if fetching has been disabled via `setPermitted`. */ 71 | refresh: () => void; 72 | destroy: () => void; 73 | } 74 | 75 | export interface Context { 76 | lastRefresh: number; 77 | retries: number; 78 | reportedError: boolean; 79 | storeEnabled: boolean; 80 | browserEnabled: boolean; 81 | permitted: boolean; 82 | } 83 | 84 | export function fetcher< 85 | T 86 | >( 87 | { 88 | name, 89 | key, 90 | autoRefreshPeriod = 60 * 60 * 1000, // default 1 hour 91 | maxBackoff = 60 * 1000, // default 1 minute 92 | fetcher, 93 | receive, 94 | initialPermitted, 95 | initialEnabled, 96 | initialData, 97 | debug, 98 | }: AutoFetcherOptions 99 | ) : AutoFetcher { 100 | name = name || key; 101 | const id = `autofetcher-${name}`; 102 | 103 | const machineDef = Machine( 104 | { 105 | id, 106 | initial: 'maybeStart', 107 | on: { 108 | FETCHER_ENABLED: { target: 'maybeStart', actions: 'updateStoreEnabled' }, 109 | SET_PERMITTED: { target: 'maybeStart', actions: 'updatePermitted' }, 110 | BROWSER_ENABLED: { 111 | target: 'maybeStart', 112 | actions: 'updateBrowserEnabled', 113 | }, 114 | INITIAL_DATA: { 115 | actions: 'receiveInitialData', 116 | }, 117 | }, 118 | states: { 119 | // Not permitted to refresh, so ignore everything except the global events that might permit us to refresh. 120 | notPermitted: { 121 | entry: ['clearData', 'clearLastRefresh'], 122 | }, 123 | // Store is disabled, but still permitted to refresh so we honor the FORCE_REFRESH event. 124 | disabled: { 125 | on: { 126 | FORCE_REFRESH: { 127 | target: 'refreshing', 128 | cond: 'permitted_to_refresh', 129 | }, 130 | }, 131 | }, 132 | maybeStart: { 133 | always: [ 134 | { cond: 'not_permitted_to_refresh', target: 'notPermitted' }, 135 | { cond: 'can_enable', target: 'waitingForRefresh' }, 136 | { target: 'disabled' }, 137 | ], 138 | }, 139 | waitingForRefresh: { 140 | on: { 141 | FORCE_REFRESH: 'refreshing', 142 | }, 143 | after: { 144 | nextRefreshDelay: 'refreshing', 145 | }, 146 | }, 147 | refreshing: { 148 | on: { 149 | // Ignore the events while we're refreshing but still update the 150 | // context so we know where to go next. 151 | FETCHER_ENABLED: { target: undefined, actions: 'updateStoreEnabled' }, 152 | SET_PERMITTED: { target: undefined, actions: 'updatePermitted' }, 153 | BROWSER_ENABLED: { 154 | target: undefined, 155 | actions: 'updateBrowserEnabled', 156 | }, 157 | }, 158 | invoke: { 159 | id: 'refresh', 160 | src: 'refresh', 161 | onDone: { 162 | target: 'maybeStart', 163 | actions: 'refreshDone', 164 | }, 165 | onError: { 166 | target: 'errorBackoff', 167 | actions: 'reportError', 168 | }, 169 | }, 170 | }, 171 | errorBackoff: { 172 | entry: 'incrementRetry', 173 | after: { 174 | errorBackoffDelay: 'refreshing', 175 | }, 176 | }, 177 | }, 178 | }, 179 | { 180 | delays: { 181 | errorBackoffDelay: (context, event) => { 182 | const baseDelay = 200; 183 | const delay = baseDelay * 2 ** Math.min(context.retries, 20); 184 | return Math.min(delay, maxBackoff); 185 | }, 186 | nextRefreshDelay: (context) => { 187 | let timeSinceRefresh = Date.now() - context.lastRefresh; 188 | let remaining = autoRefreshPeriod - timeSinceRefresh; 189 | return Math.max(remaining, 0); 190 | }, 191 | }, 192 | services: { 193 | refresh: () => fetcher(key), 194 | }, 195 | actions: { 196 | clearLastRefresh: assign({ 197 | lastRefresh: () => 0, 198 | }), 199 | updatePermitted: assign({ 200 | permitted: (ctx, event) => event.data, 201 | }), 202 | updateStoreEnabled: assign({ 203 | storeEnabled: (ctx, event) => event.data, 204 | }), 205 | updateBrowserEnabled: assign({ 206 | browserEnabled: (ctx, event) => event.data, 207 | }), 208 | receiveInitialData: assign((context: Context, {data}) => { 209 | if(context.lastRefresh) { 210 | // We already got some new data, so don't send the stale data. 211 | return {}; 212 | } 213 | 214 | let timestamp = data.timestamp || 0; 215 | receive({ 216 | data: data.data, 217 | stale: true, 218 | timestamp, 219 | }); 220 | 221 | return { 222 | // If the initial data included a timestamp, put it into lastRefresh. 223 | lastRefresh: timestamp, 224 | }; 225 | }), 226 | incrementRetry: assign({ retries: (context) => context.retries + 1 }), 227 | refreshDone: assign((context: Context, event) => { 228 | let lastRefresh = Date.now(); 229 | let updated = { 230 | lastRefresh, 231 | retries: 0, 232 | reportedError: false, 233 | }; 234 | 235 | if(event.data !== UNMODIFIED && context.permitted) { 236 | receive({ data: event.data, timestamp: lastRefresh }); 237 | } 238 | 239 | return updated; 240 | }), 241 | reportError: assign((context: Context, event) => { 242 | // Ignore the error if it happened because the browser went offline while fetching. 243 | // Otherwise report it. 244 | if ( 245 | !context.reportedError && 246 | browserStateModule.isOnline() 247 | ) { 248 | receive({ error: event.data, timestamp: Date.now() }); 249 | } 250 | 251 | return { 252 | reportedError: true, 253 | }; 254 | }), 255 | clearData: () => receive({ data: null, timestamp: Date.now() }), 256 | }, 257 | guards: { 258 | not_permitted_to_refresh: (ctx) => !ctx.permitted, 259 | permitted_to_refresh: (ctx) => ctx.permitted, 260 | can_enable: (ctx) => { 261 | if (!ctx.storeEnabled || !ctx.permitted) { 262 | return false; 263 | } 264 | 265 | if (!ctx.lastRefresh) { 266 | // Refresh if we haven't loaded any data yet. 267 | return true; 268 | } 269 | 270 | // Finally, we can enable if the browser tab is active. 271 | return ctx.browserEnabled; 272 | }, 273 | }, 274 | }, 275 | { 276 | lastRefresh: 0, 277 | retries: 0, 278 | reportedError: false, 279 | browserEnabled: true, 280 | storeEnabled: initialEnabled === false ? false : true, 281 | permitted: initialPermitted === false ? false : true, 282 | } 283 | ); 284 | 285 | let machine = interpret(machineDef).start(); 286 | 287 | if(debug) { 288 | machine.onTransition((state, event) => { 289 | debug({ id, event, state }); 290 | }); 291 | } 292 | 293 | let browserStateUnsub = browserStateModule.subscribe((state) => { 294 | let enabled = state.focused && state.online && state.visible; 295 | machine.send({ type: 'BROWSER_ENABLED', data: enabled }); 296 | }); 297 | 298 | async function fetchInitial() { 299 | let data = await initialData(key); 300 | let d = data?.data; 301 | if(d !== null && d !== undefined) { 302 | machine.send('INITIAL_DATA', {data}); 303 | } 304 | } 305 | 306 | if(initialData) { 307 | fetchInitial(); 308 | } 309 | 310 | return { 311 | /** Enable or disable the fetcher. This is usually linked to whether there is anything that actually cares about this 312 | * data or not. */ 313 | setEnabled: (enabled : boolean) => machine.send({ type: 'FETCHER_ENABLED', data: enabled }), 314 | /** Set if fetching is permitted. Fetching might not be permitted if the user is not logged in or lacks 315 | * proper permissions for this endpoint, for example. */ 316 | setPermitted: (permitted: boolean) => machine.send({ type: 'SET_PERMITTED', data: permitted }), 317 | /** Force a refresh. This will not do anything if fetching has been disabled via `setPermitted`. */ 318 | refresh: () => machine.send('FORCE_REFRESH'), 319 | destroy: () => { 320 | browserStateUnsub(); 321 | machine.stop(); 322 | }, 323 | }; 324 | } 325 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | [![Netlify Status](https://api.netlify.com/api/v1/badges/b4aa4a04-e097-4067-87ec-9a6681335673/deploy-status)](https://app.netlify.com/sites/svelte-tailwindcss-storybook/deploys) [![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2Fjerriclynsjohn%2Fsvelte-storybook-tailwind.svg?type=shield)](https://app.fossa.io/projects/git%2Bgithub.com%2Fjerriclynsjohn%2Fsvelte-storybook-tailwind?ref=badge_shield) 2 | 3 | # A starter template for Svelte, TailwindCSS and Storybook 4 | 5 | ![Svelte + TailwindCSS + Storybook Starter Template](starter-template.jpg) 6 | 7 | 8 | > Visit this website to see the outcome: [Svelte + TailwindCSS + Storybook](https://svelte-tailwindcss-storybook.netlify.com) 9 | 10 | ```bash 11 | // Quickstart 12 | 13 | npx degit jerriclynsjohn/svelte-storybook-tailwind my-svelte-project 14 | cd my-svelte-project 15 | 16 | yarn 17 | yarn dev 18 | yarn stories 19 | ``` 20 | 21 | Svelte and TailwindCSS is an awesome combination for Frontend development, but sometimes the setup seems a bit non intuitive, especially when trying to try out this awesome combination. When integrating Storybook, which is another awesome tool for UI Component development and documentation, there is no obvious place to get how it's done. This repo was made to address just that! 22 | 23 | > You can easily start your project with this template, instead of wasting time figuring out configurations for each integration. 24 | 25 | ## What do you get in this repo 26 | 27 | ![Storybook UI](Storybook-alert-modern.PNG) 28 | 29 | 1. A fully functional Svelte + TailwindCSS integration with side-by-side implementation of independent Storybook 30 | 2. Storybook with 5 essential Addons 31 | 3. Storybook populated with basic examples of Svelte + TailwindCSS 32 | 33 | ### Addons 34 | 35 | - Accessibility Addon 36 | 37 | ![Accessibility Addon](storybook-accessibility-addon.PNG) 38 | 39 | - Accessibility Addon - Colorblindness Emulation 40 | 41 | ![Accessibility Addon - Colorblindness Emulation](storybook-accessibility-addon-colorblindness-emulation.PNG) 42 | 43 | - Actions Addon 44 | 45 | ![Actions Addon](storybook-actions-addon.PNG) 46 | 47 | - Notes Addon 48 | 49 | ![Notes Addon](storybook-Documentation-Component.PNG) 50 | 51 | - Source Addon 52 | 53 | ![Source Addon](storybook-storycode-addon.PNG) 54 | 55 | - Viewport Addon 56 | 57 | ![Source Addon](storybook-viewport-addon.PNG) 58 | 59 | ## Svelte + TailwindCSS + Storybook 60 | 61 | [Storybook](https://storybook.js.org/) is an open source tool for developing JavaScript UI 62 | components in isolation 63 | 64 | [Svelte](https://svelte.dev/) is a component framework that allows you to write highly-efficient, 65 | imperative code, that surgically updates the DOM to maintain performance. 66 | 67 | [TailwindCSS](https://tailwindcss.com) is a highly customizable, low-level CSS framework that gives 68 | you all of the building blocks you need to build bespoke designs without any annoying opinionated 69 | styles you have to fight to override. 70 | 71 | ## Steps to build 72 | 73 | 1. Clone this repo `git clone https://github.com/jerriclynsjohn/svelte-storybook-tailwind.git` 74 | 2. Go to the directory `cd svelte-storybook-tailwind` 75 | 3. Install dependencies `yarn` 76 | 4. To develop your Svelte App: `yarn dev` 77 | 5. To develop UI components independent of your app: `yarn stories` 78 | 79 | ### Documentations 80 | 81 | 1. Svelte - [API](https://svelte.dev/docs) and [Tutorial](https://svelte.dev/tutorial/) 82 | 2. TailwindCSS - [Docs](https://tailwindcss.com/docs) and [Tutorial](https://tailwindcss.com/screencasts/) 83 | 3. Storybook - [Docs](https://storybook.js.org/docs/basics/introduction/) and [Tutorial (No Svelte Yet!)](https://www.learnstorybook.com/) 84 | 85 | ## Steps to build it all by yourself and some tips [Warning: It's lengthy] 86 | 87 | ### Instantiate Svelte App 88 | 89 | - Start the template file using `npx degit sveltejs/template svelte-storybook-tailwind` 90 | - Go to the directory `cd svelte-storybook-tailwind` 91 | - Install dependencies `yarn` 92 | - Try run the svelte app `yarn dev` 93 | 94 | ### Add Tailwind into the project 95 | 96 | - Install dependencies: 97 | `yarn add -D tailwindcss @fullhuman/postcss-purgecss autoprefixer postcss postcss-import svelte-preprocess` 98 | - Change the rollup config as shown: 99 | 100 | ```javascript 101 | import svelte from 'rollup-plugin-svelte'; 102 | import resolve from 'rollup-plugin-node-resolve'; 103 | import commonjs from 'rollup-plugin-commonjs'; 104 | import livereload from 'rollup-plugin-livereload'; 105 | import { terser } from 'rollup-plugin-terser'; 106 | import postcss from 'rollup-plugin-postcss'; 107 | import autoPreprocess from 'svelte-preprocess'; 108 | 109 | const production = !process.env.ROLLUP_WATCH; 110 | 111 | export default { 112 | input: 'src/main.js', 113 | output: { 114 | sourcemap: true, 115 | format: 'iife', 116 | name: 'app', 117 | file: 'public/bundle.js', 118 | }, 119 | plugins: [ 120 | svelte({ 121 | preprocess: autoPreprocess({ 122 | postcss: true, 123 | }), 124 | // enable run-time checks when not in production 125 | dev: !production, 126 | // we'll extract any component CSS out into 127 | // a separate file — better for performance 128 | css: css => { 129 | css.write('public/bundle.css'); 130 | }, 131 | }), 132 | postcss({ 133 | extract: 'public/utils.css', 134 | }), 135 | 136 | // If you have external dependencies installed from 137 | // npm, you'll most likely need these plugins. In 138 | // some cases you'll need additional configuration — 139 | // consult the documentation for details: 140 | // https://github.com/rollup/rollup-plugin-commonjs 141 | resolve({ 142 | browser: true, 143 | dedupe: importee => importee === 'svelte' || importee.startsWith('svelte/'), 144 | }), 145 | commonjs(), 146 | 147 | // Watch the `public` directory and refresh the 148 | // browser on changes when not in production 149 | !production && livereload('public'), 150 | 151 | // If we're building for production (npm run build 152 | // instead of npm run dev), minify 153 | production && terser(), 154 | ], 155 | watch: { 156 | clearScreen: false, 157 | }, 158 | }; 159 | ``` 160 | 161 | - Add tailwind config using the command `npx tailwind init` 162 | 163 | - Add PostCSS config `./postcss.config.js` as follows: 164 | 165 | ```javascript 166 | const production = !process.env.ROLLUP_WATCH; 167 | const purgecss = require('@fullhuman/postcss-purgecss'); 168 | 169 | module.exports = { 170 | plugins: [ 171 | require('postcss-import')(), 172 | require('tailwindcss'), 173 | require('autoprefixer'), 174 | production && 175 | purgecss({ 176 | content: ['./**/*.html', './**/*.svelte'], 177 | defaultExtractor: content => { 178 | const regExp = new RegExp(/[A-Za-z0-9-_:/]+/g); 179 | 180 | const matchedTokens = []; 181 | 182 | let match = regExp.exec(content); 183 | // To make sure that you do not lose any tailwind classes used in class directive. 184 | // https://github.com/tailwindcss/discuss/issues/254#issuecomment-517918397 185 | while (match) { 186 | if (match[0].startsWith('class:')) { 187 | matchedTokens.push(match[0].substring(6)); 188 | } else { 189 | matchedTokens.push(match[0]); 190 | } 191 | 192 | match = regExp.exec(content); 193 | } 194 | 195 | return matchedTokens; 196 | }, 197 | }), 198 | ], 199 | }; 200 | ``` 201 | 202 | - Build the project with some TailwindCSS utilities `yarn dev` 203 | 204 | ### Add Storybook into the Svelte Project 205 | 206 | - Add Storybook dependencies `yarn add -D @storybook/svelte` 207 | - Add 5 commonly used Storybook [Addons](https://storybook.js.org/addons/): 208 | 209 | - [Source](https://github.com/storybookjs/storybook/tree/master/addons/storysource): 210 | `yarn add -D @storybook/addon-storysource` 211 | - [Actions](https://github.com/storybookjs/storybook/tree/master/addons/actions): 212 | `yarn add -D @storybook/addon-actions` 213 | - [Notes](https://github.com/storybookjs/storybook/tree/master/addons/notes): 214 | `yarn add -D @storybook/addon-notes` 215 | - [Viewport](https://github.com/storybookjs/storybook/tree/master/addons/viewport): 216 | `yarn add -D @storybook/addon-viewport` 217 | - [Accessibility](https://github.com/storybookjs/storybook/tree/master/addons/a11y): 218 | `yarn add @storybook/addon-a11y --dev` 219 | - Create an addon file at the root `.storybook/addons.js` with the following content and keep 220 | adding additional addons in this file. 221 | 222 | ```javascript 223 | import '@storybook/addon-storysource/register'; 224 | import '@storybook/addon-actions/register'; 225 | import '@storybook/addon-notes/register'; 226 | import '@storybook/addon-viewport/register'; 227 | import '@storybook/addon-a11y/register'; 228 | ``` 229 | 230 | - Create a config file at the root `.storybook/config.js` with the following content: 231 | 232 | ```javascript 233 | import { configure, addParameters, addDecorator } from '@storybook/svelte'; 234 | import { withA11y } from '@storybook/addon-a11y'; 235 | 236 | // automatically import all files ending in *.stories.js 237 | const req = require.context('../storybook/stories', true, /\.stories\.js$/); 238 | function loadStories() { 239 | req.keys().forEach(filename => req(filename)); 240 | } 241 | 242 | configure(loadStories, module); 243 | addDecorator(withA11y); 244 | addParameters({ viewport: { viewports: newViewports } }); 245 | ``` 246 | 247 | - Add tailwind configs in the `webpack.config.js` under `.storybook` and also accommodate for Source addon: 248 | 249 | ```javascript 250 | const path = require('path'); 251 | 252 | module.exports = ({ config, mode }) => { 253 | config.module.rules.push( 254 | { 255 | test: /\.css$/, 256 | loaders: [ 257 | { 258 | loader: 'postcss-loader', 259 | options: { 260 | sourceMap: true, 261 | config: { 262 | path: './.storybook/', 263 | }, 264 | }, 265 | }, 266 | ], 267 | 268 | include: path.resolve(__dirname, '../storybook/'), 269 | }, 270 | //This is the new block for the addon 271 | { 272 | test: /\.stories\.js?$/, 273 | loaders: [require.resolve('@storybook/addon-storysource/loader')], 274 | include: [path.resolve(__dirname, '../storybook')], 275 | enforce: 'pre', 276 | }, 277 | ); 278 | 279 | return config; 280 | }; 281 | ``` 282 | 283 | - Create the `postcss.config.js` under `.storybook`: 284 | 285 | ```javascript 286 | var tailwindcss = require('tailwindcss'); 287 | 288 | module.exports = { 289 | plugins: [ 290 | require('postcss-import')(), 291 | tailwindcss('./tailwind.config.js'), 292 | require('autoprefixer'), 293 | ], 294 | }; 295 | ``` 296 | 297 | - Make sure you have babel and svelte-loader dependencies 298 | `yarn add -D babel-loader @babel/core svelte-loader` 299 | - Add npm script in your `package.json` 300 | 301 | ```bash 302 | { 303 | "scripts": { 304 | // Rest of the scripts 305 | "stories": "start-storybook", 306 | "build-stories": "build-storybook" 307 | } 308 | } 309 | ``` 310 | 311 | - Add a utils.css file under `storybook/css/` and make sure you `import 'utils.css'` in your 312 | `stories.js` files: 313 | 314 | ```css 315 | /* Import Tailwind as Global Utils */ 316 | 317 | @import 'tailwindcss/base'; 318 | 319 | @import 'tailwindcss/components'; 320 | 321 | @import 'tailwindcss/utilities'; 322 | ``` 323 | 324 | - Write your Svelte component in `storybook\components` and yes you can use your regular `.svelte` 325 | file. The only thing is that you cant use templates in a story yet, not supported, but yes you 326 | can compose other components together. For the starter pack lets just create a clickable button. 327 | 328 | ```html 329 | 337 | 338 | 342 | ``` 343 | 344 | - Write your stories in `storybook/stories` and you can name any number of story file with 345 | `.stories.js`, for the starter package we can create stories of `Button` with the 346 | readme notes at `.stories.md`. Note: reference the css here to make sure that tailwind 347 | is called by postcss: 348 | 349 | ```javascript 350 | import '../../css/utils.css'; 351 | 352 | import { storiesOf } from '@storybook/svelte'; 353 | import ButtonSimple from '../../components/buttons/button-simple.svelte'; 354 | import markdownNotes from './buttons.stories.md'; 355 | 356 | storiesOf('Buttons | Buttons', module) 357 | //Simple Button 358 | .add( 359 | 'Simple', 360 | () => ({ 361 | Component: ButtonSimple, 362 | props: { text: 'Button' }, 363 | on: { 364 | click: action('I am logging in the actions tab too'), 365 | }, 366 | }), 367 | { notes: { markdown: markdownNotes } }, 368 | ) 369 | ``` 370 | 371 | - Write your own Documentation for the Component which will `.stories.md` : 372 | 373 | ```md 374 | # Buttons 375 | 376 | _Examples of building buttons with Tailwind CSS._ 377 | 378 | --- 379 | 380 | Tailwind doesn't include pre-designed button styles out of the box, but they're easy to build using 381 | existing utilities. 382 | 383 | Here are a few examples to help you get an idea of how to build components like this using Tailwind. 384 | ``` 385 | 386 | - Run your storyboard `yarn stories` and you'll see this: 387 | 388 | ![Storybook UI](storybook-ui.PNG) 389 | 390 | You can add more addons and play around with them. 391 | 392 | That's a wrap! 393 | 394 | 395 | ## License 396 | [![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2Fjerriclynsjohn%2Fsvelte-storybook-tailwind.svg?type=large)](https://app.fossa.io/projects/git%2Bgithub.com%2Fjerriclynsjohn%2Fsvelte-storybook-tailwind?ref=badge_large) 397 | --------------------------------------------------------------------------------