├── .gitignore ├── .prettierrc ├── .travis.yml ├── LICENSE ├── README.md ├── package.json ├── src ├── css.js ├── index.js └── index.test.js └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # package distribution folder 9 | dist 10 | 11 | # Runtime data 12 | pids 13 | *.pid 14 | *.seed 15 | *.pid.lock 16 | 17 | # Directory for instrumented libs generated by jscoverage/JSCover 18 | lib-cov 19 | 20 | # Coverage directory used by tools like istanbul 21 | coverage 22 | 23 | # nyc test coverage 24 | .nyc_output 25 | 26 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 27 | .grunt 28 | 29 | # Bower dependency directory (https://bower.io/) 30 | bower_components 31 | 32 | # node-waf configuration 33 | .lock-wscript 34 | 35 | # Compiled binary addons (https://nodejs.org/api/addons.html) 36 | build/Release 37 | 38 | # Dependency directories 39 | node_modules/ 40 | jspm_packages/ 41 | 42 | # TypeScript v1 declaration files 43 | typings/ 44 | 45 | # Optional npm cache directory 46 | .npm 47 | 48 | # Optional eslint cache 49 | .eslintcache 50 | 51 | # Optional REPL history 52 | .node_repl_history 53 | 54 | # Output of 'npm pack' 55 | *.tgz 56 | 57 | # Yarn Integrity file 58 | .yarn-integrity 59 | 60 | # dotenv environment variables file 61 | .env 62 | 63 | # next.js build output 64 | .next 65 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "bracketSpacing": true, 4 | "htmlWhitespaceSensitivity": "css", 5 | "insertPragma": false, 6 | "jsxBracketSameLine": false, 7 | "jsxSingleQuote": false, 8 | "printWidth": 80, 9 | "proseWrap": "preserve", 10 | "quoteProps": "as-needed", 11 | "requirePragma": false, 12 | "semi": false, 13 | "singleQuote": true, 14 | "tabWidth": 2, 15 | "trailingComma": "none", 16 | "useTabs": false 17 | } 18 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - "10" 5 | 6 | branches: 7 | only: 8 | - master 9 | 10 | cache: yarn 11 | 12 | script: yarn test 13 | 14 | after_success: 15 | - yarn build 16 | - yarn release 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Jeremias Menichelli 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # store-css 2 | 3 | [![Build Status](https://travis-ci.org/jeremenichelli/store-css.svg)](https://travis-ci.org/jeremenichelli/store-css) 4 | 5 | 🎒 Load stylesheets asynchronously and store them in web storage. 6 | 7 | _Loads your styles without block rendering your site, and retrieves the result from web storage on future visits. Backwards compatibility with old browsers, safe implementation in case web storage fails, avoids flash of unstyled content and it can be inlined in the head of your project since it's less than 1KB in size._ 8 | 9 | ## Install 10 | 11 | ```sh 12 | # npm 13 | npm i store-css --save 14 | 15 | # yarn 16 | yarn add store-css 17 | ``` 18 | 19 | Or include it as a script with `//unpkg.com/store-css/dist/store-css.umd.js` as source. 20 | 21 | ## Usage 22 | 23 | Import the `css` method and pass the `url` where your stylesheet is located. 24 | 25 | ```js 26 | import { css } from 'store-css' 27 | 28 | const url = '//path.to/my/styles.css' 29 | css({ url }) 30 | ``` 31 | 32 | The package will unsure the stylesheet is loaded without render blocking the page. 33 | 34 | But the magic happens when you use the `storage` option. 35 | 36 | ### `storage` 37 | 38 | When you pass a `storage`, the script will save the content of the stylesheet in web storage. In future visits the styles will be retrieved from there instead of making a network call! 39 | 40 | ```js 41 | import { css } from 'store-css' 42 | 43 | const url = '//path.to/my/styles.css' 44 | const storage = 'session' 45 | css({ url, storage }) 46 | ``` 47 | 48 | 👉 `storage` option can be both `'session'` or `'local'` 49 | 50 | What happens if the web storage space is full? What happens if a browser has a buggy web storage implementation? No worries, the script will fallback to normally loading a `link` element. It **always** works. 51 | 52 | This is great to avoid _flicks_ unstyled content in repeated views, and as the script is really small it's a really good option for head inlining in static sites. 53 | 54 | ### `crossOrigin` 55 | 56 | If you are calling a stylesheet from a different origin you will need this. 57 | 58 | ```js 59 | import { css } from 'store-css' 60 | 61 | const url = '//external.source.to/my/styles.css' 62 | const storage = 'session' 63 | const crossOrigin = 'anonymous' 64 | css({ url, storage, crossOrigin }) 65 | ``` 66 | 67 | 🌎 Make sure to test which string or identifier works better for the provider 68 | 69 | ### `media` 70 | 71 | If you want styles to be aplied for a specific `media` environment, pass the query as an option. 72 | 73 | ```js 74 | import { css } from 'store-css' 75 | 76 | const url = '//path.to/my/styles.css' 77 | const storage = 'session' 78 | const media = '(max-width: 739px)' 79 | css({ url, storage, media }) 80 | ``` 81 | 82 | _On the first round the media attribute will be passed to the `link` element, on future visits the stylesheet content will be wrapped before injecting a `style` tag._ 83 | 84 | ### `ref` 85 | 86 | By default the styles will be injected before the first `script` present in the page, but you can change this if you need some specific position for them to not affect the cascade effect of the styles. 87 | 88 | ```js 89 | import { css } from 'store-css' 90 | 91 | const url = '//path.to/my/styles.css' 92 | const storage = 'session' 93 | const ref = document.getElementById('#main-styles') 94 | css({ url, storage, ref }) 95 | ``` 96 | 97 | Styles will be place **before** the `ref` element. 98 | 99 | ### `logger` 100 | 101 | If you need to debug the package behavior you can pass a `logger` method. This function will receive an error as a first argument and a message as a second one. 102 | 103 | ```js 104 | import { css } from 'store-css' 105 | 106 | const url = '//path.to/my/styles.css' 107 | const storage = 'session' 108 | const logger = console.log 109 | css({ url, storage, logger }) 110 | ``` 111 | 112 | This approach is really good for both custom logic on logging and to avoid unnecessary code in production. 113 | 114 | ```js 115 | import { css } from 'store-css' 116 | 117 | const url = '//path.to/my/styles.css' 118 | const storage = 'session' 119 | const config = { url, storage } 120 | 121 | if (process.env.NODE_ENV ==! 'production') { 122 | config.logger = (error, message) => { 123 | if (error) console.error(message, error) 124 | else console.log(message) 125 | } 126 | } 127 | 128 | css(config) 129 | ``` 130 | 131 | In production the logger method won't be added and code will be eliminated by minifiers. 132 | 133 | ## Browser support 134 | 135 | This package works all the way from modern browsers to Internet Explorer 9. 136 | 137 | ## Contributing 138 | 139 | To contribute [Node.js](//nodejs.org) and [yarn](//yarnpkg.com) are required. 140 | 141 | Before commit make sure to follow [conventional commits](//www.conventionalcommits.org) specification and check all tests pass by running `yarn test`. 142 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "store-css", 3 | "version": "0.0.0", 4 | "description": "Load stylesheets asynchronously and store them in web storage", 5 | "main": "dist/store-css.js", 6 | "browser": "dist/store-css.umd.js", 7 | "module": "dist/store-css.esm.js", 8 | "scripts": { 9 | "format": "prettier ./**/*.js --write", 10 | "test": "ava --verbose", 11 | "prebuild": "npm test", 12 | "start": "microbundle watch -i ./src/index.js --name=storecss", 13 | "build": "microbundle -i ./src/index.js --name=storecss --external none", 14 | "release": "travis-deploy-once 'semantic-release'" 15 | }, 16 | "files": [ 17 | "dist", 18 | "README.md", 19 | "LICENSE" 20 | ], 21 | "repository": { 22 | "type": "git", 23 | "url": "git+https://github.com/jeremenichelli/store-css.git" 24 | }, 25 | "keywords": [ 26 | "store-css", 27 | "critical css", 28 | "styles", 29 | "storage" 30 | ], 31 | "author": "Jeremias Menichelli", 32 | "license": "MIT", 33 | "bugs": { 34 | "url": "https://github.com/jeremenichelli/store-css/issues" 35 | }, 36 | "homepage": "https://github.com/jeremenichelli/store-css#readme", 37 | "devDependencies": { 38 | "@commitlint/cli": "^7.6.1", 39 | "@commitlint/config-conventional": "^7.6.0", 40 | "ava": "^2.0.0", 41 | "esm": "^3.2.25", 42 | "husky": "^2.3.0", 43 | "lint-staged": "^8.1.7", 44 | "lodash.clonedeep": "^4.5.0", 45 | "microbundle": "^0.11.0", 46 | "prettier": "^1.17.1", 47 | "semantic-release": "^15.9.9", 48 | "sinon": "^7.4.1", 49 | "travis-deploy-once": "^5.0.3" 50 | }, 51 | "ava": { 52 | "require": [ 53 | "esm" 54 | ] 55 | }, 56 | "lint-staged": { 57 | "./**/*.js": [ 58 | "prettier --write", 59 | "git add" 60 | ] 61 | }, 62 | "commitlint": { 63 | "extends": [ 64 | "@commitlint/config-conventional" 65 | ] 66 | }, 67 | "husky": { 68 | "hooks": { 69 | "pre-commit": "lint-staged && yarn test", 70 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS" 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/css.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Fallback function 3 | * @method noop 4 | * @returns {undefined} 5 | */ 6 | const noop = () => {} 7 | 8 | /** 9 | * Method called when a stylesheet is loaded 10 | * @method __onload__ 11 | * @param {Object} config 12 | */ 13 | function __onload__(media, storage, logger) { 14 | this.onload = null 15 | this.media = media || 'all' 16 | 17 | logger(null, `${this.href} loaded asynchronously`) 18 | 19 | if (storage) { 20 | try { 21 | const rules = this.sheet ? this.sheet.cssRules : this.styleSheet.rules 22 | let styles = '' 23 | for (var i = 0, len = rules.length; i < len; i++) { 24 | styles += rules[i].cssText 25 | } 26 | 27 | // wrap rules with @media statement if necessary 28 | if (media) styles = `@media ${media} {${styles}}` 29 | 30 | // save on web storage 31 | window[`${storage}Storage`].setItem(this.href, styles) 32 | } catch (e) { 33 | logger(e, 'Stylesheet could not be saved for future visits') 34 | } 35 | } 36 | } 37 | 38 | /** 39 | * Loads stylesheet asynchronously or retrieves it from web storage 40 | * @method css 41 | * @param {Object} config 42 | */ 43 | function css(config = {}) { 44 | const script = document.getElementsByTagName('script')[0] 45 | const ref = config.ref || script 46 | const logger = config.logger || noop 47 | const link = document.createElement('link') 48 | let storedStyles 49 | let el 50 | 51 | // create link element to extract correct href path 52 | link.rel = 'stylesheet' 53 | link.href = config.url 54 | 55 | /* 56 | * Detect stored stylesheet content only when storage option is present 57 | * and expose an error in console in case web storage is not supported 58 | */ 59 | if (config.storage) { 60 | try { 61 | storedStyles = window[`${config.storage}Storage`].getItem(link.href) 62 | } catch (error) { 63 | logger( 64 | error, 65 | `${link.href} could not be retrieved from ${config.storage}Storage` 66 | ) 67 | } 68 | } 69 | 70 | /* 71 | * if stylesheet is in web storage inject a style tag with its 72 | * content, else load it using the link tag 73 | */ 74 | if (storedStyles) { 75 | el = document.createElement('style') 76 | 77 | el.textContent = storedStyles 78 | 79 | logger(null, `${link.href} retrieved from ${config.storage}Storage`) 80 | } else { 81 | /* 82 | * Filament Group approach to prevent stylesheet to block rendering 83 | * https://github.com/filamentgroup/loadCSS/blob/master/src/loadCSS.js#L26 84 | */ 85 | link.media = 'only x' 86 | 87 | /* 88 | * Add crossOrigin attribute for external stylesheets, take in count this 89 | * attribute is not widely supported. In those cases CSS rules will not be 90 | * saved in web storage but stylesheet will be loaded 91 | */ 92 | if (config.crossOrigin) link.crossOrigin = config.crossOrigin 93 | 94 | link.onload = __onload__.bind(link, config.media, config.storage, logger) 95 | el = link 96 | } 97 | 98 | /* 99 | * Node insert approach taken from Paul Irish's 'Surefire DOM Element Insertion' 100 | * http://www.paulirish.com/2011/surefire-dom-element-insertion/ 101 | */ 102 | ref.parentNode.insertBefore(el, ref) 103 | } 104 | 105 | export default css 106 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export { default as css } from './css' 2 | -------------------------------------------------------------------------------- /src/index.test.js: -------------------------------------------------------------------------------- 1 | import { css } from '.' 2 | import { serial as test } from 'ava' 3 | import sinon from 'sinon' 4 | import clone from 'lodash.clonedeep' 5 | 6 | // mock elements to track DOM insertion and styles creation 7 | const firstRule = '.first--test { color: red; }' 8 | const secondRule = '.second--test { border: solid 1px gainsboro; }' 9 | const mockedElements = { 10 | script: { parentNode: { insertBefore: () => {} } }, 11 | link: { 12 | sheet: { 13 | cssRules: [{ cssText: firstRule }, { cssText: secondRule }] 14 | } 15 | } 16 | } 17 | 18 | // mock window object 19 | const mockedWindow = { 20 | addEventListener() {}, 21 | removeEventListener() {}, 22 | localStorage: { 23 | getItem: (key) => window.localStorage[key], 24 | setItem: (key, content) => (window.localStorage[key] = content) 25 | }, 26 | sessionStorage: { 27 | getItem: (key) => window.sessionStorage[key], 28 | setItem: (key, content) => (window.sessionStorage[key] = content) 29 | } 30 | } 31 | 32 | // mocked document object for testing 33 | const mockedDocument = { 34 | getElementsByTagName: (tag) => { 35 | if (tag === 'script') return [ELEMENTS.script] 36 | return [] 37 | }, 38 | createElement(el) { 39 | if (el === 'link') return ELEMENTS.link 40 | if (el === 'script') return ELEMENTS.script 41 | ELEMENTS[el] = {} 42 | return ELEMENTS[el] 43 | } 44 | } 45 | 46 | test.beforeEach(() => { 47 | // mocked globals 48 | global.window = clone(mockedWindow) 49 | global.document = clone(mockedDocument) 50 | 51 | // wrap storage methods with spies 52 | sinon.spy(window.localStorage, 'getItem') 53 | sinon.spy(window.localStorage, 'setItem') 54 | sinon.spy(window.sessionStorage, 'getItem') 55 | sinon.spy(window.sessionStorage, 'setItem') 56 | 57 | // mocked elements with spies and properties 58 | global.ELEMENTS = clone(mockedElements) 59 | sinon.spy(ELEMENTS.script.parentNode, 'insertBefore') 60 | }) 61 | 62 | test.afterEach(() => { 63 | sinon.restore() 64 | 65 | delete global.window 66 | delete global.document 67 | delete global.ELEMENTS 68 | }) 69 | 70 | test('loads stylesheet', (t) => { 71 | const url = 'https://path.to/stylesheet.css' 72 | css({ url }) 73 | 74 | // assigned link properties correctly 75 | t.is(ELEMENTS.link.href, url) 76 | t.is(ELEMENTS.link.media, 'only x') 77 | t.is(ELEMENTS.link.rel, 'stylesheet') 78 | 79 | // test element injection in DOM 80 | t.is( 81 | ELEMENTS.script.parentNode.insertBefore.getCall(0).args[0], 82 | ELEMENTS.link 83 | ) 84 | t.is( 85 | ELEMENTS.script.parentNode.insertBefore.getCall(0).args[1], 86 | ELEMENTS.script 87 | ) 88 | 89 | // switched media to all on load 90 | ELEMENTS.link.onload() 91 | t.is(ELEMENTS.link.media, 'all') 92 | }) 93 | 94 | test('accepts media in config object', (t) => { 95 | const url = 'https://path.to/stylesheet.css' 96 | const media = '(max-width: 739px' 97 | css({ url, media }) 98 | 99 | ELEMENTS.link.onload() 100 | t.is(ELEMENTS.link.media, media) 101 | }) 102 | 103 | test('accepts cross origin attribute', (t) => { 104 | const url = 'https://path.to/stylesheet.css' 105 | const crossOrigin = 'anonymous' 106 | css({ url, crossOrigin }) 107 | 108 | t.is(ELEMENTS.link.crossOrigin, crossOrigin) 109 | }) 110 | 111 | test('accepts a reference element for link injection', (t) => { 112 | const url = 'https://path.to/stylesheet.css' 113 | const ref = { parentNode: { insertBefore: sinon.spy() } } 114 | css({ url, ref }) 115 | 116 | t.is(ref.parentNode.insertBefore.getCall(0).args[0], ELEMENTS.link) 117 | t.is(ref.parentNode.insertBefore.getCall(0).args[1], ref) 118 | }) 119 | 120 | test('stores result in localStorage', (t) => { 121 | const url = 'https://path.to/stylesheet.css' 122 | const storage = 'local' 123 | css({ url, storage }) 124 | 125 | // styles gets to local storage 126 | ELEMENTS.link.onload() 127 | const result = `${firstRule}${secondRule}` 128 | t.is(window.localStorage.setItem.getCall(0).args[0], url) 129 | t.is(window.localStorage.setItem.getCall(0).args[1], result) 130 | }) 131 | 132 | test('stores result in sessionStorage', (t) => { 133 | const url = 'https://path.to/stylesheet.css' 134 | const storage = 'session' 135 | css({ url, storage }) 136 | 137 | // styles gets to session storage 138 | ELEMENTS.link.onload() 139 | const result = `${firstRule}${secondRule}` 140 | t.is(window.sessionStorage.setItem.getCall(0).args[0], url) 141 | t.is(window.sessionStorage.setItem.getCall(0).args[1], result) 142 | }) 143 | 144 | test('retrieves already saved styles from localStorage', (t) => { 145 | const url = 'https://path.to/stylesheet.css' 146 | const storage = 'local' 147 | const styles = `${firstRule}${secondRule}` 148 | window.localStorage[url] = styles 149 | css({ url, storage }) 150 | 151 | t.is(window.localStorage.getItem.getCall(0).args[0], url) 152 | t.is(ELEMENTS.style.textContent, styles) 153 | }) 154 | 155 | test('retrieves already saved styles from sessionStorage', (t) => { 156 | const url = 'https://path.to/stylesheet.css' 157 | const storage = 'session' 158 | const styles = `${firstRule}${secondRule}` 159 | window.sessionStorage[url] = styles 160 | css({ url, storage }) 161 | 162 | t.is(window.sessionStorage.getItem.getCall(0).args[0], url) 163 | t.is(ELEMENTS.style.textContent, styles) 164 | }) 165 | 166 | test('wraps styles in media when storing', (t) => { 167 | const url = 'https://path.to/stylesheet.css' 168 | const media = '(max-width: 739px)' 169 | const storage = 'session' 170 | const styles = `${firstRule}${secondRule}` 171 | css({ url, media, storage }) 172 | 173 | ELEMENTS.link.onload() 174 | t.is( 175 | window.sessionStorage.setItem.getCall(0).args[1], 176 | `@media ${media} {${styles}}` 177 | ) 178 | }) 179 | 180 | test('accepts logger method', (t) => { 181 | const url = 'https://path.to/stylesheet.css' 182 | const storage = 'session' 183 | const logger = sinon.spy() 184 | css({ url, storage, logger }) 185 | 186 | ELEMENTS.link.onload() 187 | t.is(logger.getCall(0).args[0], null) 188 | t.is(logger.getCall(0).args[1], `${url} loaded asynchronously`) 189 | }) 190 | --------------------------------------------------------------------------------