├── .editorconfig ├── .eslintrc ├── .eslintrc-md.json ├── .github └── FUNDING.yml ├── .gitignore ├── .npmignore ├── .travis.yml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── babel.config.js ├── jest-puppeteer.config.js ├── jest.config.js ├── package.json ├── rollup.config.js ├── src ├── LazyHydrate.js └── utils │ ├── hydration-blocker.js │ ├── hydration-observer.js │ ├── hydration-promise.js │ └── nonce.js ├── test ├── integration │ ├── components │ │ ├── DummyIdle.vue │ │ ├── DummyInteraction.vue │ │ ├── DummySsr.vue │ │ ├── DummyVisible.vue │ │ ├── IntegrationAsync.vue │ │ └── IntegrationSync.vue │ ├── entry-integration.js │ ├── integration.test.js │ ├── render.js │ └── template.html └── performance │ ├── .eslintrc │ ├── benchmark.js │ ├── components │ ├── DeeplyNested.vue │ ├── HydrateNever.vue │ ├── LongList.vue │ ├── Markdown.vue │ ├── Reference.vue │ └── ShortList.vue │ ├── entry-hydrate-never.js │ ├── entry-reference.js │ ├── render.js │ ├── template-hydrate-never.html │ └── template-reference.html └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | indent_size = 2 9 | indent_style = space 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | 13 | [*.md] 14 | trim_trailing_whitespace = false 15 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "node": true, 5 | "jest": true 6 | }, 7 | "parserOptions": { 8 | "ecmaVersion": 9, 9 | "parser": "babel-eslint" 10 | }, 11 | "plugins": ["compat"], 12 | "extends": [ 13 | "plugin:vue/recommended", 14 | "@avalanche/eslint-config" 15 | ], 16 | "rules": { 17 | "compat/compat": 2, 18 | "vue/component-name-in-template-casing": ["error", 19 | "PascalCase" 20 | ], 21 | "vue/no-v-html": "off", 22 | "vue/html-closing-bracket-spacing": ["error", { 23 | "startTag": "never", 24 | "endTag": "never", 25 | "selfClosingTag": "never" 26 | }] 27 | }, 28 | "settings": { 29 | "polyfills": [ 30 | "IntersectionObserver", 31 | "Map", 32 | "Promise" 33 | ] 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /.eslintrc-md.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["markdown"], 3 | "extends": ".eslintrc", 4 | "rules": { 5 | "quotes": [2, "single", { "avoidEscape": true }], 6 | "import/extensions": "off", 7 | "import/no-extraneous-dependencies": "off", 8 | "import/no-unresolved": "off" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | custom: "https://www.paypal.com/paypalme/maoberlehner" 2 | github: maoberlehner 3 | ko_fi: maoberlehner 4 | open_collective: vue-lazy-hydration 5 | patreon: maoberlehner 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Numerous always-ignore extensions 2 | *.diff 3 | *.err 4 | *.log 5 | *.orig 6 | *.rej 7 | *.swo 8 | *.swp 9 | *.tgz 10 | *.vi 11 | *.zip 12 | *~ 13 | 14 | # OS or Editor folders 15 | ._* 16 | .cache 17 | .DS_Store 18 | .idea 19 | .project 20 | .settings 21 | .tmproj 22 | *.esproj 23 | *.sublime-project 24 | *.sublime-workspace 25 | nbproject 26 | Thumbs.db 27 | 28 | # Folders to ignore 29 | coverage 30 | dist 31 | node_modules 32 | test/package 33 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .editorconfig 2 | .eslintrc 3 | .eslintrc-md.json 4 | .travis.yml 5 | 6 | babel.config.js 7 | CODE_OF_CONDUCT.md 8 | CONTRIBUTING.md 9 | jest-puppeteer.config.js 10 | jest.config.js 11 | rollup.config.js 12 | 13 | test 14 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "node" 4 | before_install: 5 | - curl -o- -L https://yarnpkg.com/install.sh | bash 6 | - export PATH="$HOME/.yarn/bin:$PATH" 7 | script: 8 | - yarn run lint 9 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | - Using welcoming and inclusive language 12 | - Being respectful of differing viewpoints and experiences 13 | - Gracefully accepting constructive criticism 14 | - Focusing on what is best for the community 15 | - Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | - The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | - Trolling, insulting/derogatory comments, and personal or political attacks 21 | - Public or private harassment 22 | - Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | - Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at markus.oberlehner@gmail.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Reporting Issues 4 | 5 | Found a problem? Want a new feature? 6 | 7 | - See if your issue or idea has [already been reported]. 8 | - Provide a [reduced test case] or a [live example]. 9 | 10 | Remember, a bug is a *demonstrable problem* caused by *our* code. 11 | 12 | ## Submitting Pull Requests 13 | 14 | Pull requests are the greatest contributions, so be sure they are focused in scope, and do avoid unrelated commits. 15 | 16 | 1. To begin, [fork this project], clone your fork, and add our upstream. 17 | ```bash 18 | # Clone your fork of the repo into the current directory 19 | git clone https://github.com//vue-lazy-hydration 20 | # Navigate to the newly cloned directory 21 | cd vue-lazy-hydration 22 | # Assign the original repo to a remote called "upstream" 23 | git remote add upstream https://github.com/maoberlehner/vue-lazy-hydration 24 | # Install the tools necessary for development 25 | yarn install 26 | ``` 27 | 2. Create a branch for your feature or hotfix: 28 | ```bash 29 | # Move into a new branch for a feature 30 | git checkout -b feature/thing 31 | ``` 32 | ```bash 33 | # Move into a new branch for a hotfix 34 | git checkout -b hotfix/something 35 | ``` 36 | 3. Push your branch up to your fork: 37 | ```bash 38 | # Push a feature branch 39 | git push origin feature/thing 40 | ``` 41 | ```bash 42 | # Push a hotfix branch 43 | git push origin hotfix/something 44 | ``` 45 | 4. Now [open a pull request] with a clear title and description. 46 | 47 | [already been reported]: https://github.com/maoberlehner/vue-lazy-hydration/issues 48 | [fork this project]: https://github.com/maoberlehner/vue-lazy-hydration/fork 49 | [live example]: http://codepen.io/pen 50 | [open a pull request]: https://help.github.com/articles/using-pull-requests/ 51 | [reduced test case]: https://css-tricks.com/reduced-test-cases/ 52 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Markus Oberlehner 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 | # vue-lazy-hydration 2 | 3 | [![Patreon](https://img.shields.io/badge/patreon-donate-blue.svg)](https://www.patreon.com/maoberlehner) 4 | [![Donate](https://img.shields.io/badge/Donate-PayPal-blue.svg)](https://paypal.me/maoberlehner) 5 | [![Build Status](https://travis-ci.org/maoberlehner/vue-lazy-hydration.svg?branch=master)](https://travis-ci.org/maoberlehner/vue-lazy-hydration) 6 | [![GitHub stars](https://img.shields.io/github/stars/maoberlehner/vue-lazy-hydration.svg?style=social&label=Star)](https://github.com/maoberlehner/vue-lazy-hydration) 7 | 8 | > Lazy Hydration of Server-Side Rendered Vue.js Components 9 | 10 | [![ko-fi](https://www.ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/O4O7U55Y) 11 | 12 | `vue-lazy-hydration` is a renderless Vue.js component to **improve Estimated Input Latency and Time to Interactive** of server-side rendered Vue.js applications. This can be achieved **by using lazy hydration to delay the hydration of pre-rendered HTML**. 13 | 14 | ## Install 15 | 16 | ```bash 17 | npm install vue-lazy-hydration 18 | ``` 19 | 20 | ```js 21 | import LazyHydrate from 'vue-lazy-hydration'; 22 | // ... 23 | 24 | export default { 25 | // ... 26 | components: { 27 | LazyHydrate, 28 | // ... 29 | }, 30 | // ... 31 | }; 32 | ``` 33 | 34 | ## Basic example 35 | 36 | In the example below you can see the four hydration modes in action. 37 | 38 | ```html 39 | 67 | 68 | 82 | ``` 83 | 84 | 1. Because it is at the very top of the page, the `ImageSlider` should be hydrated eventually, but we can wait until the browser is idle. 85 | 2. The `ArticleContent` component is never hydrated on the client, which also means it will never be interactive (static content only). 86 | 3. Next we can see the `AdSlider` beneath the article content, this component will most likely not be visible initially so we can delay hydration until the point it becomes visible. 87 | 4. At the very bottom of the page we want to render a `CommentForm` but because most people only read the article and don't leave a comment, we can save resources by only hydrating the component whenever it actually receives focus. 88 | 89 | ## Advanced 90 | 91 | ### Manually trigger hydration 92 | 93 | Sometimes you might want to prevent a component from loading initially but you want to activate it on demand if a certain action is triggered. You can do this by manually triggering the component to hydrate like you can see in the following example. 94 | 95 | ```html 96 | 106 | 107 | 123 | ``` 124 | 125 | ### Multiple root nodes 126 | 127 | Because of how this package works, it is not possible to nest multiple root nodes inside of a single ``. But you can wrap multiple components with a `
`. 128 | 129 | ```html 130 | 142 | ``` 143 | 144 | ### Intersection Observer options 145 | 146 | Internally the [Intersection Observer API](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver/IntersectionObserver) is used to determine if a component is visible or not. You can provide Intersection Observer options to the `when-visible` property to configure the Intersection Observer. 147 | 148 | ```html 149 | 156 | ``` 157 | 158 | For a list of possible options please [take a look at the Intersection Observer API documentation on MDN](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver/IntersectionObserver). 159 | 160 | ## Import Wrappers 161 | 162 | Additionally to the `` wrapper component you can also use Import Wrappers to lazy load and hydrate certain components. 163 | 164 | ```html 165 | 173 | 174 | 200 | ``` 201 | 202 | ## Benchmarks 203 | 204 | ### Without lazy hydration 205 | 206 | ![Without lazy hydration.](https://res.cloudinary.com/maoberlehner/image/upload/c_scale,f_auto,q_auto,w_600/v1532158513/github/vue-lazy-hydration/no-lazy-hydration-demo-benchmark) 207 | 208 | ### With lazy hydration 209 | 210 | ![With lazy hydration.](https://res.cloudinary.com/maoberlehner/image/upload/c_scale,f_auto,q_auto,w_600/v1532158513/github/vue-lazy-hydration/lazy-hydration-demo-benchmark) 211 | 212 | ## Caveats 213 | 214 | **This plugin will not work as advertised if you're not using it in combination with SSR.** Although it should work with every pre-rendering approach (like [Prerender SPA Plugin](https://github.com/chrisvfritz/prerender-spa-plugin), [Gridsome](https://gridsome.org/), ...) I've only tested it with [Nuxt.js](https://nuxtjs.org) so far. 215 | 216 | ## Upgrade v1.x to v2.x 217 | 218 | Breaking changes: 219 | 220 | - `ssr-only` was renamed to `never` (as in "Hydrate this? Never!"). 221 | 222 | ```diff 223 | - 224 | + 225 | 226 | 227 | ``` 228 | 229 | - Specyfing `ignored-props` on Import Wrappers is not necessary anymore. 230 | 231 | ```diff 232 | components: { 233 | - ArticleContent: hydrateNever(() => import('./ArticleContent.vue'), { ignoredProps: ['content'] }), 234 | + ArticleContent: hydrateNever(() => import('./ArticleContent.vue')), 235 | } 236 | ``` 237 | 238 | ## Articles 239 | 240 | - [Partial Hydration Concepts: Lazy and Active](https://markus.oberlehner.net/blog/partial-hydration-concepts-lazy-and-active/) 241 | - [abomination: a Concept for a Static HTML / Dynamic JavaScript Hybrid Application](https://markus.oberlehner.net/blog/abomination-a-concept-for-a-static-html-dynamic-javascript-hybrid-application/) 242 | - [How to Drastically Reduce Estimated Input Latency and Time to Interactive of SSR Vue.js Applications](https://markus.oberlehner.net/blog/how-to-drastically-reduce-estimated-input-latency-and-time-to-interactive-of-ssr-vue-applications/) 243 | 244 | ## Credits 245 | 246 | The code of the v1 version of this package was based on a [similar package created by **Rahul Kadyan**](https://github.com/znck/lazy-hydration). 247 | 248 | ## Testing 249 | 250 | Because the core functionality of `vue-lazy-hydration` heavily relies on browser APIs like `IntersectionObserver` and `requestIdleCallback()`, it is tough to write meaningful unit tests without having to write numerous mocks. Because of that, we mostly use integration tests and some performance benchmarks to test the functionality of this package. 251 | 252 | ### Integration tests 253 | 254 | Execute the following commands to run the integration tests: 255 | 256 | ```bash 257 | npm run test:integration:build 258 | npm run test:integration 259 | ``` 260 | 261 | ### Performance tests 262 | 263 | Execute the following commands to run the performance benchmark: 264 | 265 | ```bash 266 | npm run test:perf:build 267 | npm run test:perf 268 | ``` 269 | 270 | ## About 271 | 272 | ### Author 273 | 274 | Markus Oberlehner 275 | Website: https://markus.oberlehner.net 276 | Twitter: https://twitter.com/MaOberlehner 277 | PayPal.me: https://paypal.me/maoberlehner 278 | Patreon: https://www.patreon.com/maoberlehner 279 | 280 | ### License 281 | 282 | MIT 283 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | [ 4 | `@babel/preset-env`, 5 | { 6 | modules: false, 7 | }, 8 | ], 9 | ], 10 | env: { 11 | test: { 12 | presets: [ 13 | [`@babel/preset-env`, 14 | { 15 | targets: { 16 | node: `current`, 17 | }, 18 | }, 19 | ], 20 | ], 21 | }, 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /jest-puppeteer.config.js: -------------------------------------------------------------------------------- 1 | const DEBUG_MODE = process.argv.includes(`--debug`); 2 | 3 | module.exports = { 4 | launch: DEBUG_MODE ? { 5 | headless: false, 6 | slowMo: 100, 7 | } : {}, 8 | }; 9 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: `jest-puppeteer`, 3 | }; 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-lazy-hydration", 3 | "version": "2.0.0-beta.4", 4 | "description": "Lazy Hydration of Server-Side Rendered Vue.js Components", 5 | "keywords": [ 6 | "gridsome", 7 | "hydration", 8 | "nuxt", 9 | "ssr", 10 | "vue" 11 | ], 12 | "author": "Markus Oberlehner", 13 | "homepage": "https://github.com/maoberlehner/vue-lazy-hydration", 14 | "license": "MIT", 15 | "scripts": { 16 | "scripts:umd": "rollup --config --output.format umd --name vue-lazy-hydration --output.file dist/LazyHydrate.js src/LazyHydrate.js", 17 | "scripts:es": "rollup --config --output.format es --name vue-lazy-hydration --output.file dist/LazyHydrate.esm.js src/LazyHydrate.js", 18 | "scripts:minify": "uglifyjs --compress --mangle --comments --output dist/LazyHydrate.min.js dist/LazyHydrate.js && uglifyjs --compress --mangle --comments --output dist/LazyHydrate.esm.min.js dist/LazyHydrate.esm.js", 19 | "scripts": "yarn run scripts:umd && yarn run scripts:es && yarn run scripts:minify", 20 | "build": "yarn run scripts", 21 | "lint:scripts": "eslint --ignore-path .gitignore .", 22 | "lint:scripts-md": "eslint --config .eslintrc-md.json --ext md --ignore-path .gitignore .", 23 | "lint": "yarn run lint:scripts && yarn run lint:scripts-md", 24 | "test:perf:compile": "npx vue-cli-service build --target lib --no-clean --dest test/performance/dist --name 'entry-reference' test/performance/entry-reference.js && npx vue-cli-service build --target lib --no-clean --dest test/performance/dist --name 'entry-hydrate-never' test/performance/entry-hydrate-never.js", 25 | "test:perf:render": "node test/performance/render.js", 26 | "test:perf:build": "npm run build && npm run test:perf:compile && npm run test:perf:render", 27 | "test:perf:serve": "serve test/performance/dist", 28 | "test:perf": "concurrently 'npm:test:perf:serve' 'node test/performance/benchmark.js' --kill-others", 29 | "test:integration:compile": "npx vue-cli-service build --target lib --no-clean --dest test/integration/dist --name 'entry-integration' test/integration/entry-integration.js", 30 | "test:integration:render": "node test/integration/render.js", 31 | "test:integration:build": "npm run build && npm run test:integration:compile && npm run test:integration:render", 32 | "test:integration:serve": "serve test/integration/dist", 33 | "test:integration": "concurrently 'npm:test:integration:serve' 'jest test/integration/integration.test.js' --kill-others", 34 | "prepublishOnly": "yarn run lint && yarn run build" 35 | }, 36 | "devDependencies": { 37 | "@avalanche/eslint-config": "^4.0.0", 38 | "@babel/core": "^7.12.3", 39 | "@babel/preset-env": "^7.12.1", 40 | "@vue/cli-service": "^4.5.9", 41 | "babel-eslint": "^10.1.0", 42 | "cli-table3": "^0.6.0", 43 | "concurrently": "^5.3.0", 44 | "eslint": "^7.13.0", 45 | "eslint-plugin-compat": "^3.8.0", 46 | "eslint-plugin-import": "^2.22.1", 47 | "eslint-plugin-markdown": "^1.0.2", 48 | "eslint-plugin-vue": "^7.1.0", 49 | "jest": "^26.6.3", 50 | "jest-puppeteer": "^4.4.0", 51 | "lighthouse": "^6.4.1", 52 | "marked": "^1.2.4", 53 | "puppeteer": "^5.5.0", 54 | "rollup": "^2.33.3", 55 | "rollup-plugin-babel": "^4.4.0", 56 | "serve": "^11.3.2", 57 | "uglify-es": "^3.3.9", 58 | "vue": "^2.6.12", 59 | "vue-server-renderer": "^2.6.12", 60 | "vue-template-compiler": "^2.6.12" 61 | }, 62 | "main": "dist/LazyHydrate.js", 63 | "module": "dist/LazyHydrate.esm.js", 64 | "repository": { 65 | "type": "git", 66 | "url": "https://github.com/maoberlehner/vue-lazy-hydration" 67 | }, 68 | "bugs": { 69 | "url": "https://github.com/maoberlehner/vue-lazy-hydration/issues" 70 | }, 71 | "browserslist": [ 72 | "> 0.5%", 73 | "not ie <= 10", 74 | "not op_mini all" 75 | ] 76 | } 77 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from 'rollup-plugin-babel'; 2 | 3 | export default { 4 | plugins: [ 5 | babel(), 6 | ], 7 | }; 8 | -------------------------------------------------------------------------------- /src/LazyHydrate.js: -------------------------------------------------------------------------------- 1 | import { makeHydrationBlocker } from './utils/hydration-blocker'; 2 | 3 | export function hydrateWhenIdle(componentOrFactory, { timeout = 2000 } = {}) { 4 | return makeHydrationBlocker(componentOrFactory, { 5 | beforeCreate() { 6 | this.whenIdle = true; 7 | this.idleTimeout = timeout; 8 | }, 9 | }); 10 | } 11 | 12 | export function hydrateWhenVisible(componentOrFactory, { observerOptions = undefined } = {}) { 13 | return makeHydrationBlocker(componentOrFactory, { 14 | beforeCreate() { 15 | this.whenVisible = observerOptions || true; 16 | }, 17 | }); 18 | } 19 | 20 | export function hydrateNever(componentOrFactory) { 21 | return makeHydrationBlocker(componentOrFactory, { 22 | beforeCreate() { 23 | this.never = true; 24 | }, 25 | }); 26 | } 27 | 28 | export function hydrateOnInteraction(componentOrFactory, { event = `focus` } = {}) { 29 | const events = Array.isArray(event) ? event : [event]; 30 | 31 | return makeHydrationBlocker(componentOrFactory, { 32 | beforeCreate() { 33 | this.interactionEvents = events; 34 | }, 35 | }); 36 | } 37 | 38 | const Placeholder = { 39 | render() { 40 | return this.$slots.default; 41 | }, 42 | }; 43 | 44 | export default makeHydrationBlocker(Placeholder, { 45 | props: { 46 | idleTimeout: { 47 | default: 2000, 48 | type: Number, 49 | }, 50 | never: { 51 | type: Boolean, 52 | }, 53 | onInteraction: { 54 | type: [Array, Boolean, String], 55 | }, 56 | triggerHydration: { 57 | default: false, 58 | type: Boolean, 59 | }, 60 | whenIdle: { 61 | type: Boolean, 62 | }, 63 | whenVisible: { 64 | type: [Boolean, Object], 65 | }, 66 | }, 67 | computed: { 68 | interactionEvents() { 69 | if (!this.onInteraction) return []; 70 | if (this.onInteraction === true) return [`focus`]; 71 | 72 | return Array.isArray(this.onInteraction) 73 | ? this.onInteraction 74 | : [this.onInteraction]; 75 | }, 76 | }, 77 | watch: { 78 | triggerHydration: { 79 | immediate: true, 80 | handler(isTriggered) { 81 | if (isTriggered) this.hydrate(); 82 | }, 83 | }, 84 | }, 85 | }); 86 | -------------------------------------------------------------------------------- /src/utils/hydration-blocker.js: -------------------------------------------------------------------------------- 1 | import { makeHydrationObserver } from './hydration-observer'; 2 | import { makeHydrationPromise } from './hydration-promise'; 3 | import { makeNonce } from './nonce'; 4 | 5 | export function makeHydrationBlocker(component, options) { 6 | return Object.assign({ 7 | mixins: [{ 8 | beforeCreate() { 9 | this.cleanupHandlers = []; 10 | const { hydrate, hydrationPromise } = makeHydrationPromise(); 11 | this.Nonce = makeNonce({ component, hydrationPromise }); 12 | this.hydrate = hydrate; 13 | this.hydrationPromise = hydrationPromise; 14 | }, 15 | beforeDestroy() { 16 | this.cleanup(); 17 | }, 18 | mounted() { 19 | if (this.$el.nodeType === Node.COMMENT_NODE) { 20 | // No SSR rendered content, hydrate immediately. 21 | this.hydrate(); 22 | return; 23 | } 24 | 25 | if (this.never) return; 26 | 27 | if (this.whenVisible) { 28 | const observerOptions = this.whenVisible !== true ? this.whenVisible : undefined; 29 | const observer = makeHydrationObserver(observerOptions); 30 | 31 | // If Intersection Observer API is not supported, hydrate immediately. 32 | if (!observer) { 33 | this.hydrate(); 34 | return; 35 | } 36 | 37 | this.$el.hydrate = this.hydrate; 38 | const cleanup = () => observer.unobserve(this.$el); 39 | this.cleanupHandlers.push(cleanup); 40 | this.hydrationPromise.then(cleanup); 41 | observer.observe(this.$el); 42 | return; 43 | } 44 | 45 | if (this.whenIdle) { 46 | // If `requestIdleCallback()` or `requestAnimationFrame()` 47 | // is not supported, hydrate immediately. 48 | if (!(`requestIdleCallback` in window) || !(`requestAnimationFrame` in window)) { 49 | this.hydrate(); 50 | return; 51 | } 52 | 53 | // @ts-ignore 54 | const id = requestIdleCallback(() => { 55 | requestAnimationFrame(this.hydrate); 56 | }, { timeout: this.idleTimeout }); 57 | // @ts-ignore 58 | const cleanup = () => cancelIdleCallback(id); 59 | this.cleanupHandlers.push(cleanup); 60 | this.hydrationPromise.then(cleanup); 61 | } 62 | 63 | if (this.interactionEvents && this.interactionEvents.length) { 64 | const eventListenerOptions = { 65 | capture: true, 66 | once: true, 67 | passive: true, 68 | }; 69 | 70 | this.interactionEvents.forEach((eventName) => { 71 | this.$el.addEventListener(eventName, this.hydrate, eventListenerOptions); 72 | const cleanup = () => { 73 | this.$el.removeEventListener(eventName, this.hydrate, eventListenerOptions); 74 | }; 75 | this.cleanupHandlers.push(cleanup); 76 | }); 77 | } 78 | }, 79 | methods: { 80 | cleanup() { 81 | this.cleanupHandlers.forEach(handler => handler()); 82 | }, 83 | }, 84 | render(h) { 85 | return h(this.Nonce, { 86 | attrs: this.$attrs, 87 | on: this.$listeners, 88 | scopedSlots: this.$scopedSlots, 89 | }, this.$slots.default); 90 | }, 91 | }], 92 | }, options); 93 | } 94 | -------------------------------------------------------------------------------- /src/utils/hydration-observer.js: -------------------------------------------------------------------------------- 1 | const observers = new Map(); 2 | 3 | export function makeHydrationObserver(options) { 4 | if (typeof IntersectionObserver === `undefined`) return null; 5 | 6 | const optionKey = JSON.stringify(options); 7 | if (observers.has(optionKey)) return observers.get(optionKey); 8 | 9 | const observer = new IntersectionObserver((entries) => { 10 | entries.forEach((entry) => { 11 | // Use `intersectionRatio` because of Edge 15's 12 | // lack of support for `isIntersecting`. 13 | // See: https://github.com/w3c/IntersectionObserver/issues/211 14 | const isIntersecting = entry.isIntersecting || entry.intersectionRatio > 0; 15 | if (!isIntersecting || !entry.target.hydrate) return; 16 | entry.target.hydrate(); 17 | }); 18 | }, options); 19 | observers.set(optionKey, observer); 20 | 21 | return observer; 22 | } 23 | -------------------------------------------------------------------------------- /src/utils/hydration-promise.js: -------------------------------------------------------------------------------- 1 | export function makeHydrationPromise() { 2 | let hydrate = () => {}; 3 | const hydrationPromise = new Promise((resolve) => { 4 | hydrate = resolve; 5 | }); 6 | 7 | return { 8 | hydrate, 9 | hydrationPromise, 10 | }; 11 | } 12 | -------------------------------------------------------------------------------- /src/utils/nonce.js: -------------------------------------------------------------------------------- 1 | const isServer = typeof window === `undefined`; 2 | 3 | function isAsyncComponentFactory(componentOrFactory) { 4 | return typeof componentOrFactory === `function`; 5 | } 6 | 7 | function resolveComponent(componentOrFactory) { 8 | if (isAsyncComponentFactory(componentOrFactory)) { 9 | return componentOrFactory().then(componentModule => componentModule.default); 10 | } 11 | return componentOrFactory; 12 | } 13 | 14 | export function makeNonce({ component, hydrationPromise }) { 15 | if (isServer) return component; 16 | 17 | return () => hydrationPromise.then(() => resolveComponent(component)); 18 | } 19 | -------------------------------------------------------------------------------- /test/integration/components/DummyIdle.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 26 | -------------------------------------------------------------------------------- /test/integration/components/DummyInteraction.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 38 | -------------------------------------------------------------------------------- /test/integration/components/DummySsr.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 26 | -------------------------------------------------------------------------------- /test/integration/components/DummyVisible.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 26 | -------------------------------------------------------------------------------- /test/integration/components/IntegrationAsync.vue: -------------------------------------------------------------------------------- 1 | 190 | 191 | 225 | -------------------------------------------------------------------------------- /test/integration/components/IntegrationSync.vue: -------------------------------------------------------------------------------- 1 | 190 | 191 | 225 | -------------------------------------------------------------------------------- /test/integration/entry-integration.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | 3 | import IntegrationAsync from './components/IntegrationAsync.vue'; 4 | import IntegrationSync from './components/IntegrationSync.vue'; 5 | 6 | export const AppAsync = new Vue({ 7 | render: h => h(IntegrationAsync), 8 | }).$mount(`#app-async`); 9 | 10 | export const AppSync = new Vue({ 11 | render: h => h(IntegrationSync), 12 | }).$mount(`#app-sync`); 13 | -------------------------------------------------------------------------------- /test/integration/integration.test.js: -------------------------------------------------------------------------------- 1 | /* global page */ 2 | export const open = ( 3 | url, 4 | options = { waitUntil: `networkidle0` }, 5 | ) => page.goto(`http://localhost:5000${url}`, options); 6 | 7 | export const find = async (selector) => { 8 | await page.waitForSelector(selector); 9 | return page.$(selector); 10 | }; 11 | 12 | describe.each([`async`, `sync`])(`%s`, (variant) => { 13 | describe(``, () => { 14 | test(`It should hydrate the component when the browser is idle.`, async () => { 15 | await open(`/integration-${variant}`, {}); 16 | 17 | let moreText = await page.$(`.DummyIdle .more`); 18 | expect(moreText).toBe(null); 19 | 20 | moreText = await find(`.DummyIdle .more`); 21 | expect(moreText).not.toBe(null); 22 | }); 23 | }); 24 | 25 | describe(`hydrateWhenIdle()`, () => { 26 | test(`It should hydrate the component when the browser is idle.`, async () => { 27 | await open(`/integration-${variant}`, {}); 28 | 29 | let moreText = await page.$(`.DummyIdle.wrapper .more`); 30 | expect(moreText).toBe(null); 31 | 32 | moreText = await find(`.DummyIdle.wrapper .more`); 33 | expect(moreText).not.toBe(null); 34 | }); 35 | }); 36 | 37 | describe(``, () => { 38 | test(`It should hydrate the component when it becomes visible.`, async () => { 39 | await open(`/integration-${variant}`); 40 | 41 | let moreText = await page.$(`.DummyVisible .more`); 42 | expect(moreText).toBe(null); 43 | 44 | await page.evaluate(() => { 45 | document.querySelector(`.DummyVisible`).scrollIntoView(); 46 | }); 47 | 48 | moreText = await find(`.DummyVisible .more`); 49 | expect(moreText).not.toBe(null); 50 | }); 51 | }); 52 | 53 | describe(`hydrateWhenVisible()`, () => { 54 | test(`It should hydrate the component when it becomes visible.`, async () => { 55 | await open(`/integration-${variant}`); 56 | 57 | let moreText = await page.$(`.DummyVisible.wrapper .more`); 58 | expect(moreText).toBe(null); 59 | 60 | await page.evaluate(() => { 61 | document.querySelector(`.DummyVisible.wrapper`).scrollIntoView(); 62 | }); 63 | 64 | moreText = await find(`.DummyVisible.wrapper .more`); 65 | expect(moreText).not.toBe(null); 66 | }); 67 | }); 68 | 69 | describe(``, () => { 70 | test(`It should hydrate the component when an interaction happens.`, async () => { 71 | await open(`/integration-${variant}`); 72 | 73 | let moreText = await page.$(`.DummyInteraction .more`); 74 | expect(moreText).toBe(null); 75 | 76 | let button = await find(`.DummyInteraction .button`); 77 | await button.click(); 78 | button = await find(`.DummyInteraction .button`); 79 | await button.click(); 80 | 81 | moreText = await find(`.DummyInteraction .more`); 82 | expect(moreText).not.toBe(null); 83 | }); 84 | 85 | test(`It should render show slot content.`, async () => { 86 | await open(`/integration-${variant}`); 87 | 88 | let defaultSlot = await find(`.DummyInteraction .default-slot`); 89 | expect(defaultSlot).not.toBe(null); 90 | let namedSlot = await find(`.DummyInteraction .named-slot`); 91 | expect(namedSlot).not.toBe(null); 92 | 93 | let button = await find(`.DummyInteraction .button`); 94 | await button.click(); 95 | button = await find(`.DummyInteraction .button`); 96 | await button.click(); 97 | 98 | defaultSlot = await find(`.DummyInteraction .default-slot`); 99 | expect(defaultSlot).not.toBe(null); 100 | namedSlot = await find(`.DummyInteraction .named-slot`); 101 | expect(namedSlot).not.toBe(null); 102 | }); 103 | 104 | test(`It should be possible to listen to events triggerd by lazy component.`, async () => { 105 | await open(`/integration-${variant}`); 106 | 107 | let button = await find(`.DummyInteraction .button`); 108 | await button.click(); 109 | button = await find(`.DummyInteraction .button-some-event`); 110 | await button.click(); 111 | 112 | const div = await find(`.DummyInteraction .show-when-event`); 113 | expect(div).not.toBe(null); 114 | }); 115 | }); 116 | 117 | describe(`hydrateOnInteraction()`, () => { 118 | test(`It should hydrate the component when an interaction happens.`, async () => { 119 | await open(`/integration-${variant}`); 120 | 121 | let moreText = await page.$(`.DummyInteraction.wrapper .more`); 122 | expect(moreText).toBe(null); 123 | 124 | let button = await find(`.DummyInteraction.wrapper .button`); 125 | await button.click(); 126 | button = await find(`.DummyInteraction.wrapper .button`); 127 | await button.click(); 128 | 129 | moreText = await find(`.DummyInteraction.wrapper .more`); 130 | expect(moreText).not.toBe(null); 131 | }); 132 | 133 | test(`It should render show slot content.`, async () => { 134 | await open(`/integration-${variant}`); 135 | 136 | let defaultSlot = await find(`.DummyInteraction.wrapper .default-slot`); 137 | expect(defaultSlot).not.toBe(null); 138 | let namedSlot = await find(`.DummyInteraction.wrapper .named-slot`); 139 | expect(namedSlot).not.toBe(null); 140 | 141 | let button = await find(`.DummyInteraction.wrapper .button`); 142 | await button.click(); 143 | button = await find(`.DummyInteraction.wrapper .button`); 144 | await button.click(); 145 | 146 | defaultSlot = await find(`.DummyInteraction.wrapper .default-slot`); 147 | expect(defaultSlot).not.toBe(null); 148 | namedSlot = await find(`.DummyInteraction.wrapper .named-slot`); 149 | expect(namedSlot).not.toBe(null); 150 | }); 151 | 152 | test(`It should be possible to listen to events triggerd by lazy component.`, async () => { 153 | await open(`/integration-${variant}`); 154 | 155 | let button = await find(`.DummyInteraction.wrapper .button`); 156 | await button.click(); 157 | button = await find(`.DummyInteraction.wrapper .button-some-event`); 158 | await button.click(); 159 | 160 | const div = await find(`.DummyInteraction.wrapper .show-when-event`); 161 | expect(div).not.toBe(null); 162 | }); 163 | }); 164 | 165 | describe(``, () => { 166 | test(`It should not hydrate the component.`, async () => { 167 | await open(`/integration-${variant}`); 168 | 169 | const component = await find(`.DummySsr`); 170 | expect(component).not.toBe(null); 171 | 172 | const moreText = await page.$(`.DummySsr .more`); 173 | expect(moreText).toBe(null); 174 | }); 175 | }); 176 | 177 | describe(`hydrateNever()`, () => { 178 | test(`It should not hydrate the component.`, async () => { 179 | await open(`/integration-${variant}`); 180 | 181 | const component = await find(`.DummySsr.wrapper`); 182 | expect(component).not.toBe(null); 183 | 184 | const moreText = await page.$(`.DummySsr.wrapper .more`); 185 | expect(moreText).toBe(null); 186 | }); 187 | }); 188 | }); 189 | -------------------------------------------------------------------------------- /test/integration/render.js: -------------------------------------------------------------------------------- 1 | const fs = require(`fs`); 2 | const vueServerRenderer = require(`vue-server-renderer`); 3 | 4 | const entryIntegration = require(`./dist/entry-integration.common.js`); 5 | 6 | function saveFile(name, contents) { 7 | fs.writeFile(`${__dirname}/dist/${name}.html`, contents, (error) => { 8 | if (error) throw error; 9 | 10 | // eslint-disable-next-line no-console 11 | console.log(`${name} rendered.`); 12 | }); 13 | } 14 | 15 | const integrationRenderer = vueServerRenderer.createRenderer({ 16 | template: fs.readFileSync(`${__dirname}/template.html`, `utf-8`), 17 | }); 18 | 19 | integrationRenderer.renderToString(entryIntegration.AppAsync, (error, html) => { 20 | if (error) throw error; 21 | 22 | saveFile(`integration-async`, html); 23 | }); 24 | 25 | integrationRenderer.renderToString(entryIntegration.AppSync, (error, html) => { 26 | if (error) throw error; 27 | 28 | saveFile(`integration-sync`, html); 29 | }); 30 | -------------------------------------------------------------------------------- /test/integration/template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | integration test 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /test/performance/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "node": true 5 | }, 6 | "parserOptions": { 7 | "ecmaVersion": 9 8 | }, 9 | "extends": [ 10 | "plugin:vue/recommended", 11 | "@avalanche/eslint-config" 12 | ], 13 | "rules": { 14 | "vue/component-name-in-template-casing": ["error", 15 | "PascalCase" 16 | ], 17 | "vue/no-v-html": "off", 18 | "vue/html-closing-bracket-spacing": ["error", { 19 | "startTag": "never", 20 | "endTag": "never", 21 | "selfClosingTag": "never" 22 | }] 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /test/performance/benchmark.js: -------------------------------------------------------------------------------- 1 | const chromeLauncher = require(`chrome-launcher`); 2 | const lighthouse = require(`lighthouse`); 3 | const Table = require(`cli-table3`); 4 | 5 | function launchChromeAndRunLighthouse(url, opts, config = null) { 6 | return chromeLauncher.launch({ chromeFlags: opts.chromeFlags }).then((chrome) => { 7 | // eslint-disable-next-line no-param-reassign 8 | opts.port = chrome.port; 9 | return lighthouse(url, opts, config) 10 | .then(results => chrome.kill().then(() => results.lhr)); 11 | }); 12 | } 13 | 14 | const config = { 15 | extends: `lighthouse:default`, 16 | settings: { 17 | onlyAudits: [ 18 | `estimated-input-latency`, 19 | `first-cpu-idle`, 20 | `interactive`, 21 | `bootup-time`, 22 | ], 23 | }, 24 | }; 25 | 26 | async function run() { 27 | const table = new Table({ 28 | head: [``, `Estimated Input Latency`, `First CPU Idle`, `Time to Interactive`, `Bootup Time`], 29 | }); 30 | 31 | const reference = await launchChromeAndRunLighthouse(`http://localhost:5000/reference.html`, {}, config); 32 | const hydrateNever = await launchChromeAndRunLighthouse(`http://localhost:5000/hydrate-never.html`, {}, config); 33 | 34 | table.push(...[ 35 | { 36 | Reference: [ 37 | reference.audits[`estimated-input-latency`].displayValue, 38 | reference.audits[`first-cpu-idle`].displayValue, 39 | reference.audits.interactive.displayValue, 40 | reference.audits[`bootup-time`].displayValue, 41 | ], 42 | }, 43 | { 44 | 'hydrate never': [ 45 | hydrateNever.audits[`estimated-input-latency`].displayValue, 46 | hydrateNever.audits[`first-cpu-idle`].displayValue, 47 | hydrateNever.audits.interactive.displayValue, 48 | hydrateNever.audits[`bootup-time`].displayValue, 49 | ], 50 | }, 51 | ]); 52 | 53 | // eslint-disable-next-line no-console 54 | console.log(table.toString()); 55 | } 56 | 57 | run(); 58 | -------------------------------------------------------------------------------- /test/performance/components/DeeplyNested.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 26 | -------------------------------------------------------------------------------- /test/performance/components/HydrateNever.vue: -------------------------------------------------------------------------------- 1 | 37 | 38 | 55 | -------------------------------------------------------------------------------- /test/performance/components/LongList.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 23 | -------------------------------------------------------------------------------- /test/performance/components/Markdown.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 20 | -------------------------------------------------------------------------------- /test/performance/components/Reference.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 37 | -------------------------------------------------------------------------------- /test/performance/components/ShortList.vue: -------------------------------------------------------------------------------- 1 | 12 | -------------------------------------------------------------------------------- /test/performance/entry-hydrate-never.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | 3 | import HydrateNever from './components/HydrateNever.vue'; 4 | 5 | export const App = new Vue({ 6 | render: h => h(HydrateNever), 7 | }).$mount(`#app`); 8 | -------------------------------------------------------------------------------- /test/performance/entry-reference.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | 3 | import Reference from './components/Reference.vue'; 4 | 5 | export const App = new Vue({ 6 | render: h => h(Reference), 7 | }).$mount(`#app`); 8 | -------------------------------------------------------------------------------- /test/performance/render.js: -------------------------------------------------------------------------------- 1 | const vueServerRenderer = require(`vue-server-renderer`); 2 | const fs = require(`fs`); 3 | 4 | const entryReference = require(`./dist/entry-reference.common.js`); 5 | const entryHydrateNever = require(`./dist/entry-hydrate-never.common.js`); 6 | 7 | function saveFile(name, contents) { 8 | fs.writeFile(`${__dirname}/dist/${name}.html`, contents, (error) => { 9 | if (error) throw error; 10 | 11 | // eslint-disable-next-line no-console 12 | console.log(`${name} rendered.`); 13 | }); 14 | } 15 | 16 | const referenceRenderer = vueServerRenderer.createRenderer({ 17 | template: fs.readFileSync(`${__dirname}/template-reference.html`, `utf-8`), 18 | }); 19 | 20 | referenceRenderer.renderToString(entryReference.App, (error, html) => { 21 | if (error) throw error; 22 | 23 | saveFile(`reference`, html); 24 | }); 25 | 26 | const hydrateNeverRenderer = vueServerRenderer.createRenderer({ 27 | template: fs.readFileSync(`${__dirname}/template-hydrate-never.html`, `utf-8`), 28 | }); 29 | 30 | hydrateNeverRenderer.renderToString(entryHydrateNever.App, (error, html) => { 31 | if (error) throw error; 32 | 33 | saveFile(`hydrate-never`, html); 34 | }); 35 | -------------------------------------------------------------------------------- /test/performance/template-hydrate-never.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | performance test 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /test/performance/template-reference.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | performance test 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | --------------------------------------------------------------------------------