├── .eslintrc.cjs ├── .github ├── dependabot.yml └── workflows │ ├── dependency-review.yml │ ├── release-please.yml │ └── run-unit-test.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── .vscode └── extensions.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── api-extractor.json ├── commitlint.config.cjs ├── demo ├── App.vue ├── assets │ └── main.scss ├── collect-css-ssr.ts ├── components │ ├── CounterComp.vue │ ├── HydrationState.vue │ ├── InputComp.vue │ ├── InputHydratedOnInteraction.vue │ ├── LazilyHydratedCounter.vue │ └── NavLinks.vue ├── entry-client.ts ├── entry-server.ts ├── main.ts ├── router.ts ├── routes.ts └── views │ ├── component │ └── LazyHydrationWrapperDemo.vue │ ├── composables │ ├── HydrateOnInteractionDemo.vue │ ├── HydrateWhenIdleDemo.vue │ ├── HydrateWhenTriggeredDemo.vue │ ├── HydrateWhenVisibleDemo.vue │ └── LazyHydrationDemo.vue │ └── import-wrappers │ ├── HydrateNeverDemo.vue │ ├── HydrateOnInteractionDemo.vue │ ├── HydrateWhenIdleDemo.vue │ ├── HydrateWhenTriggeredDemo.vue │ └── HydrateWhenVisibleDemo.vue ├── index.html ├── package.json ├── plugin-dev-server.ts ├── pnpm-lock.yaml ├── public └── favicon.ico ├── renovate.json ├── src ├── components │ ├── LazyHydrationWrapper.spec.ts │ └── LazyHydrationWrapper.ts ├── composables │ ├── index.ts │ ├── useHydrateOnInteraction.spec.ts │ ├── useHydrateOnInteraction.ts │ ├── useHydrateWhenIdle.spec.ts │ ├── useHydrateWhenIdle.ts │ ├── useHydrateWhenTriggered.spec.ts │ ├── useHydrateWhenTriggered.ts │ ├── useHydrateWhenVisible.spec.ts │ ├── useHydrateWhenVisible.ts │ ├── useLazyHydration.spec.ts │ └── useLazyHydration.ts ├── env.d.ts ├── index.ts ├── utils │ ├── create-hydration-cleanup.ts │ ├── create-hydration-observer.ts │ ├── create-hydration-promise.ts │ ├── create-hydration-wrapper.spec.ts │ ├── create-hydration-wrapper.ts │ ├── ensure-parent-has-subtree-el.ts │ ├── get-root-elements.ts │ ├── helpers.ts │ ├── index.ts │ ├── track-deps-on-render.ts │ ├── traverse-children.spec.ts │ ├── traverse-children.ts │ └── wait-for-async-components.ts └── wrappers │ ├── hydrate-never.spec.ts │ ├── hydrate-never.ts │ ├── hydrate-on-interaction.spec.ts │ ├── hydrate-on-interaction.ts │ ├── hydrate-when-idle.spec.ts │ ├── hydrate-when-idle.ts │ ├── hydrate-when-triggered.spec.ts │ ├── hydrate-when-triggered.ts │ ├── hydrate-when-visible.spec.ts │ ├── hydrate-when-visible.ts │ └── index.ts ├── test ├── dom-mocks │ ├── index.ts │ ├── intersection-observer.ts │ └── request-idle-callback.ts ├── setupVitestEnv.ts └── utils.ts ├── tsconfig.json ├── tsconfig.types.json └── vite.config.ts /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | require('@rushstack/eslint-patch/modern-module-resolution'); 2 | const { defineConfig } = require('eslint-define-config'); 3 | 4 | module.exports = defineConfig({ 5 | root: true, 6 | env: { 7 | browser: true, 8 | node: true, 9 | 'vitest-globals/env': true, 10 | }, 11 | ignorePatterns: ['node_modules', 'dist', 'temp'], 12 | extends: [ 13 | 'plugin:vue/vue3-recommended', 14 | '@vue/eslint-config-airbnb-with-typescript', 15 | 'plugin:vitest-globals/recommended', 16 | 'prettier', 17 | ], 18 | rules: { 19 | 'no-param-reassign': ['error', { props: false }], 20 | 'import/no-extraneous-dependencies': [ 21 | 'error', 22 | { 23 | devDependencies: true, 24 | }, 25 | ], 26 | }, 27 | }); 28 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Fetch and update latest `github-actions` pkgs 4 | - package-ecosystem: github-actions 5 | directory: '/' 6 | schedule: 7 | interval: daily 8 | time: '00:00' 9 | open-pull-requests-limit: 10 10 | rebase-strategy: 'auto' 11 | reviewers: 12 | - freddy38510 13 | assignees: 14 | - freddy38510 15 | commit-message: 16 | prefix: fix 17 | prefix-development: chore 18 | include: scope 19 | -------------------------------------------------------------------------------- /.github/workflows/dependency-review.yml: -------------------------------------------------------------------------------- 1 | # Dependency Review Action 2 | # 3 | # This Action will scan dependency manifest files that change as part of a Pull Request, surfacing known-vulnerable versions of the packages declared or updated in the PR. Once installed, if the workflow run is marked as required, PRs introducing known-vulnerable packages will be blocked from merging. 4 | # 5 | # Source repository: https://github.com/actions/dependency-review-action 6 | # Public documentation: https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-dependency-review#dependency-review-enforcement 7 | name: 'Dependency Review' 8 | on: [pull_request] 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | dependency-review: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: 'Checkout Repository' 18 | uses: actions/checkout@v3 19 | - name: 'Dependency Review' 20 | uses: actions/dependency-review-action@v3 21 | -------------------------------------------------------------------------------- /.github/workflows/release-please.yml: -------------------------------------------------------------------------------- 1 | name: release-please 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | tests: 11 | uses: ./.github/workflows/run-unit-test.yml 12 | release-please: 13 | needs: [tests] # require tests to pass before release-please runs 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: google-github-actions/release-please-action@v3 17 | with: 18 | release-type: node 19 | package-name: vue3-lazy-hydration 20 | -------------------------------------------------------------------------------- /.github/workflows/run-unit-test.yml: -------------------------------------------------------------------------------- 1 | name: Runs All Unit tests 2 | 3 | on: 4 | workflow_call: 5 | 6 | jobs: 7 | test: 8 | runs-on: ${{ matrix.os }} 9 | 10 | timeout-minutes: 10 11 | 12 | strategy: 13 | matrix: 14 | os: [ubuntu-latest] 15 | node_version: [14, 16] 16 | 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v3 20 | 21 | - name: Install pnpm 22 | uses: pnpm/action-setup@v2.2.4 23 | 24 | - name: Set node version to ${{ matrix.node_version }} 25 | uses: actions/setup-node@v3 26 | with: 27 | node-version: ${{ matrix.node_version }} 28 | cache: 'pnpm' 29 | 30 | - name: Install deps 31 | run: pnpm i --frozen-lockfile 32 | 33 | - name: Test unit 34 | run: pnpm run test:unit 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | .DS_Store 12 | dist 13 | dist-ssr 14 | coverage 15 | *.local 16 | .eslintcache 17 | yarn.lock 18 | package-lock.json 19 | .pnpm-store/ 20 | temp 21 | 22 | /cypress/videos/ 23 | /cypress/screenshots/ 24 | 25 | # Editor directories and files 26 | .vscode/* 27 | !.vscode/extensions.json 28 | .idea 29 | *.suo 30 | *.ntvs* 31 | *.njsproj 32 | *.sln 33 | *.sw? 34 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | LICENSE.md 3 | pnpm-lock.yaml 4 | CHANGELOG.md 5 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "tabWidth": 2, 4 | "singleQuote": true 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "vue.volar", 4 | "Vue.vscode-typescript-vue-plugin", 5 | "esbenp.prettier-vscode", 6 | "zixuanchen.vitest-explorer" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [1.2.1](https://github.com/freddy38510/vue3-lazy-hydration/compare/v1.2.0...v1.2.1) (2022-09-23) 4 | 5 | 6 | ### Bug Fixes 7 | 8 | * **package:** remove pre/post install scripts that break package installation as a dependency ([3674171](https://github.com/freddy38510/vue3-lazy-hydration/commit/367417190e7248bac8bff9dea6ad80c71c044cc1)), closes [#42](https://github.com/freddy38510/vue3-lazy-hydration/issues/42) 9 | 10 | ## [1.2.0](https://github.com/freddy38510/vue3-lazy-hydration/compare/v1.1.3...v1.2.0) (2022-09-23) 11 | 12 | 13 | ### Features 14 | 15 | * switch to Typescript and provide type declaration file ([b2ba9cc](https://github.com/freddy38510/vue3-lazy-hydration/commit/b2ba9cc75b5f1f5489e9fa97bbf84b3a49133a7f)), closes [#36](https://github.com/freddy38510/vue3-lazy-hydration/issues/36) 16 | 17 | ## [1.1.3](https://github.com/freddy38510/vue3-lazy-hydration/compare/v1.1.2...v1.1.3) (2022-08-09) 18 | 19 | 20 | ### Bug Fixes 21 | 22 | * **deps:** move Vue dependency to devDependencies ([454ef9f](https://github.com/freddy38510/vue3-lazy-hydration/commit/454ef9f146632b60201279a07ed4bc0f9e842d84)), closes [#22](https://github.com/freddy38510/vue3-lazy-hydration/issues/22) 23 | 24 | ## [1.1.2](https://github.com/freddy38510/vue3-lazy-hydration/compare/v1.1.1...v1.1.2) (2022-07-20) 25 | 26 | 27 | ### Bug Fixes 28 | 29 | * **import-wrappers:** ensure the "useLazyHydration" composable is called at server-side ([b1041ba](https://github.com/freddy38510/vue3-lazy-hydration/commit/b1041ba767b7fbb572979997c953f4c828e56e1b)) 30 | 31 | ## [1.1.1](https://github.com/freddy38510/vue3-lazy-hydration/compare/v1.1.0...v1.1.1) (2022-07-18) 32 | 33 | 34 | ### Bug Fixes 35 | 36 | * **import-wrappers:** properly handle env variables for production build ([76ec143](https://github.com/freddy38510/vue3-lazy-hydration/commit/76ec143dd8ea0b97cd5a0189e4cbc8f670b3a43f)) 37 | 38 | ## [1.1.0](https://github.com/freddy38510/vue3-lazy-hydration/compare/v1.0.0...v1.1.0) (2022-07-18) 39 | 40 | 41 | ### Features 42 | 43 | * **import-wrappers:** supports asynchronous loading of wrapped component ([63f7983](https://github.com/freddy38510/vue3-lazy-hydration/commit/63f7983d0a30bc4804ee527e52e68072ef3bf1aa)) 44 | 45 | 46 | ### Bug Fixes 47 | 48 | * **composables:** use a trust parameter to conditionally run composables ([77071ca](https://github.com/freddy38510/vue3-lazy-hydration/commit/77071ca5c46e9e0b0cc1ddae34e9b36a79a6cbeb)) 49 | 50 | ## 1.0.0 (2022-07-14) 51 | 52 | ### Features 53 | 54 | - initial release 55 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Freddy Escobar 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 | # vue3-lazy-hydration 2 | 3 | > Lazy Hydration of Server-Side Rendered Vue.js v3 Components 4 | 5 | Inspired by [`vue-lazy-hydration`](https://github.com/maoberlehner/vue-lazy-hydration), this library brings a renderless component, composables and import wrappers to delay the hydration of pre-rendered HTML. 6 | 7 | ## Installation 8 | 9 | Use [yarn v1](https://classic.yarnpkg.com/), [npm](https://github.com/npm/cli) or [pnpm](https://pnpm.io/) package manager to install vue3-lazy-hydration. 10 | 11 | ```bash 12 | # install with yarn 13 | yarn add vue3-lazy-hydration 14 | 15 | # install with npm 16 | npm install vue3-lazy-hydration 17 | 18 | # install with pnpm 19 | pnpm add vue3-lazy-hydration 20 | ``` 21 | 22 | ### Importing Renderless Component 23 | 24 | If you want to use the renderless component you can either import it directly inside your Vue SFCs (see examples below) or make it [available globally](https://vuejs.org/guide/components/registration.html#global-registration). 25 | 26 | #### Global import for Vue 27 | 28 | ```js 29 | import { createSSRApp } from 'vue'; 30 | import { LazyHydrationWrapper } from 'vue3-lazy-hydration'; 31 | 32 | const app = createSSRApp({}); 33 | 34 | app.component('LazyHydrationWrapper', LazyHydrationWrapper); 35 | 36 | // or, you can use a custom registered name: 37 | // use instead of 38 | app.component('LazyHydrate', LazyHydrationWrapper); 39 | ``` 40 | 41 | #### Global import for Nuxt 3 42 | 43 | Thanks to [Baroshem](https://github.com/Baroshem) the Nuxt 3 plugin [nuxt-lazy-hydrate](https://github.com/Baroshem/nuxt-lazy-hydrate) is available for this purpose. 44 | 45 | ## Usage 46 | 47 | ### Renderless Component 48 | 49 | - Never hydrate. 50 | 51 | ```html 52 | 59 | 60 | 67 | ``` 68 | 69 | - Delays hydration until the browser is idle. 70 | 71 | ```html 72 | 80 | 87 | ``` 88 | 89 | - Delays hydration until one of the root elements is visible. 90 | 91 | ```html 92 | 103 | 104 | 111 | ``` 112 | 113 | - Delays hydration until one of the elements triggers a DOM event (focus by default). 114 | 115 | ```html 116 | 126 | 127 | 134 | ``` 135 | 136 | - Delays hydration until manually triggered. 137 | 138 | ```html 139 | 148 | 149 | 163 | ``` 164 | 165 | #### Props declaration 166 | 167 | ```js 168 | props: { 169 | 170 | /* Number type refers to the timeout option passed to the requestIdleCallback API 171 | * @see https://developer.mozilla.org/en-US/docs/Web/API/Window/requestIdleCallback 172 | */ 173 | whenIdle: { 174 | default: false, 175 | type: [Boolean, Number], 176 | }, 177 | 178 | /* Object type refers to the options passed to the IntersectionObserver API 179 | * @see https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API 180 | */ 181 | whenVisible: { 182 | default: false, 183 | type: [Boolean, Object], 184 | }, 185 | 186 | /* 187 | * @see https://developer.mozilla.org/en-US/docs/Web/API/Element#events 188 | */ 189 | onInteraction: { 190 | default: false, 191 | type: [Array, Boolean, String], 192 | }, 193 | 194 | /* 195 | * Object type refers to a ref object 196 | * @see https://vuejs.org/api/reactivity-core.html#ref 197 | */ 198 | whenTriggered: { 199 | default: undefined, 200 | type: [Boolean, Object], 201 | }, 202 | } 203 | ``` 204 | 205 | ### Composables 206 | 207 | #### `useLazyHydration()` 208 | 209 | ```html 210 | 240 | ``` 241 | 242 | #### `useHydrateWhenIdle({ willPerformHydration, hydrate, onCleanup }, timeout = 2000)` 243 | 244 | ```html 245 | 256 | ``` 257 | 258 | #### `useHydrateWhenVisible({ hydrate, onCleanup }, observerOpts = {})` 259 | 260 | ```html 261 | 279 | ``` 280 | 281 | #### `useHydrateOnInteraction({ hydrate, onCleanup }, events = ['focus'])` 282 | 283 | ```html 284 | 293 | ``` 294 | 295 | #### `useHydrateWhenTriggered({ willPerformHydration, hydrate, onCleanup }, trigger)` 296 | 297 | ```html 298 | 318 | ``` 319 | 320 | ### Import Wrappers 321 | 322 | #### `hydrateNever(source)` 323 | 324 | Wrap a component in a renderless component that will never be hydrated. 325 | 326 | ```html 327 | 338 | 339 | 344 | ``` 345 | 346 | #### `hydrateWhenIdle(source, timeout = 2000)` 347 | 348 | Wrap a component in a renderless component that will be hydrated when browser is idle. 349 | 350 | ```html 351 | 368 | 369 | 374 | ``` 375 | 376 | #### `hydrateWhenVisible(source, observerOpts = {})` 377 | 378 | Wrap a component in a renderless component that will be hydrated when one of the root elements is visible. 379 | 380 | ```html 381 | 400 | 401 | 406 | ``` 407 | 408 | #### `hydrateOnInteraction(source, events = ['focus'])` 409 | 410 | Wrap a component in a renderless component that will be hydrated when one of the elements trigger one of the events in the `events` parameter. 411 | 412 | ```html 413 | 429 | 430 | 435 | ``` 436 | 437 | #### `hydrateWhenTriggered(source, trigger)` 438 | 439 | Wrap a component in a renderless component that will be hydrated when the `trigger` parameter changes to true. 440 | 441 | ```html 442 | 465 | 466 | 473 | ``` 474 | 475 | ## Contributing 476 | 477 | Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change. 478 | 479 | Please make sure to update unit tests as appropriate. 480 | 481 | ### Development 482 | 483 | Use the [pnpm](https://pnpm.io/) package manager to install vue3-lazy-hydration. 484 | 485 | 1. Clone the repository 486 | 487 | ```bash 488 | git clone https://github.com/freddy38510/vue3-lazy-hydration.git 489 | 490 | cd vue3-lazy-hydration 491 | ``` 492 | 493 | 2. Install dependencies 494 | 495 | ```bash 496 | pnpm i 497 | ``` 498 | 499 | 3. Start the development server which hosts a demo application to help develop the library 500 | 501 | ```bash 502 | pnpm dev 503 | ``` 504 | 505 | ## Credits 506 | 507 | Many thanks to **Markus Oberlehner**, the author of the package 508 | [vue-lazy-hydration](https://github.com/maoberlehner/vue-lazy-hydration). 509 | 510 | ## License 511 | 512 | [MIT](https://github.com/freddy38510/vue3-lazy-hydration/blob/master/LICENSE) 513 | -------------------------------------------------------------------------------- /api-extractor.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", 3 | "mainEntryPointFilePath": "/temp/index.d.ts", 4 | "bundledPackages": [], 5 | "compiler": { 6 | "tsconfigFilePath": "/tsconfig.types.json" 7 | }, 8 | "apiReport": { 9 | "enabled": false 10 | }, 11 | "docModel": { 12 | "enabled": false 13 | }, 14 | "dtsRollup": { 15 | "enabled": true, 16 | "publicTrimmedFilePath": "/dist/.d.ts" 17 | }, 18 | "tsdocMetadata": { 19 | "enabled": false 20 | }, 21 | "messages": { 22 | "compilerMessageReporting": { 23 | "default": { 24 | "logLevel": "warning" 25 | } 26 | }, 27 | "extractorMessageReporting": { 28 | "default": { 29 | "logLevel": "warning" 30 | } 31 | }, 32 | "tsdocMessageReporting": { 33 | "default": { 34 | "logLevel": "warning" 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /commitlint.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'], 3 | }; 4 | -------------------------------------------------------------------------------- /demo/App.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 20 | -------------------------------------------------------------------------------- /demo/assets/main.scss: -------------------------------------------------------------------------------- 1 | :root { 2 | font-size: 16px; 3 | } 4 | 5 | * { 6 | line-height: 1.5; 7 | } 8 | 9 | body { 10 | color: #333; 11 | font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 12 | 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 13 | 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; 14 | font-size: 14px; 15 | margin: 0; 16 | padding: 0 0.5rem; 17 | } 18 | 19 | code { 20 | font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 21 | 'Liberation Mono', 'Courier New', monospace; 22 | font-size: 1em; 23 | } 24 | 25 | h1, 26 | h2 { 27 | text-align: center; 28 | } 29 | 30 | h1 { 31 | color: #1f1f1f; 32 | margin: 0.75rem 0 1.5rem 0; 33 | } 34 | 35 | h2 { 36 | margin: 1.5rem 0; 37 | } 38 | 39 | h3 { 40 | font-size: 1rem; 41 | margin: 1rem 0; 42 | } 43 | 44 | p { 45 | margin: 0 0 1.25em 0; 46 | } 47 | 48 | p:last-child { 49 | margin: 0; 50 | } 51 | 52 | hr { 53 | margin: 1.5rem 0; 54 | } 55 | 56 | ul { 57 | margin: 0; 58 | padding-left: 1em; 59 | } 60 | 61 | li { 62 | padding: 0.0625em 0; 63 | } 64 | 65 | .layout { 66 | max-width: 600px; 67 | margin: auto; 68 | padding-bottom: 1.5rem; 69 | } 70 | 71 | .box { 72 | border-radius: 6px; 73 | box-shadow: rgba(0, 0, 0, 0.05) 0px 0px 0px 1px, 74 | rgb(209, 213, 219) 0px 0px 0px 1px inset; 75 | padding: 1.5rem; 76 | } 77 | 78 | .sticky { 79 | position: sticky; 80 | top: 0; 81 | z-index: 10000; 82 | background: white; 83 | } 84 | 85 | .push-down { 86 | margin-top: 1500px; 87 | } 88 | -------------------------------------------------------------------------------- /demo/collect-css-ssr.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-underscore-dangle */ 2 | /* eslint-disable no-bitwise */ 3 | 4 | import type { ModuleNode } from 'vite'; 5 | 6 | const hashCode = (moduleId: string) => { 7 | let hash = 0; 8 | let i; 9 | let chr; 10 | if (moduleId.length === 0) return hash; 11 | for (i = 0; i < moduleId.length; i += 1) { 12 | chr = moduleId.charCodeAt(i); 13 | hash = (hash << 5) - hash + chr; 14 | hash |= 0; // Convert to 32bit integer 15 | } 16 | return hash; 17 | }; 18 | 19 | const moduleIsStyle = (mod: ModuleNode) => 20 | (mod?.file?.endsWith('.scss') || 21 | mod?.file?.endsWith('.css') || 22 | mod?.id?.includes('vue&type=style')) && 23 | mod?.ssrModule; 24 | 25 | /** 26 | * Collect SSR CSS for Vite 27 | */ 28 | export const collectCss = ( 29 | mods: ModuleNode[] | Set, 30 | styles = new Map(), 31 | checkedMods = new Set() 32 | ) => { 33 | let result = ''; 34 | 35 | mods.forEach((mod) => { 36 | if (moduleIsStyle(mod)) { 37 | styles.set(mod.url, mod.ssrModule?.default); 38 | } 39 | 40 | if (mod?.importedModules?.size > 0 && !checkedMods.has(mod.id)) { 41 | checkedMods.add(mod.id); 42 | 43 | collectCss(mod.importedModules, styles, checkedMods); 44 | } 45 | }); 46 | 47 | styles.forEach((content, id) => { 48 | result = result.concat( 49 | `` 50 | ); 51 | }); 52 | 53 | return result; 54 | }; 55 | 56 | /** 57 | * Client listener to detect updated modules through HMR, 58 | * and remove the initial styled attached to the head 59 | */ 60 | export const removeCssHotReloaded = () => { 61 | if (!import.meta.hot) { 62 | return; 63 | } 64 | 65 | import.meta.hot.on('vite:beforeUpdate', (module) => { 66 | module.updates.forEach((update) => { 67 | const moduleStyle = document.querySelector( 68 | `[vite-module-id="${hashCode(update.acceptedPath)}"]` 69 | ); 70 | 71 | if (moduleStyle) { 72 | moduleStyle.remove(); 73 | } 74 | }); 75 | }); 76 | }; 77 | -------------------------------------------------------------------------------- /demo/components/CounterComp.vue: -------------------------------------------------------------------------------- 1 | 10 | 20 | -------------------------------------------------------------------------------- /demo/components/HydrationState.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 53 | 54 | 67 | -------------------------------------------------------------------------------- /demo/components/InputComp.vue: -------------------------------------------------------------------------------- 1 | 10 | 25 | -------------------------------------------------------------------------------- /demo/components/InputHydratedOnInteraction.vue: -------------------------------------------------------------------------------- 1 | 19 | 34 | -------------------------------------------------------------------------------- /demo/components/LazilyHydratedCounter.vue: -------------------------------------------------------------------------------- 1 | 50 | 60 | -------------------------------------------------------------------------------- /demo/components/NavLinks.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 91 | 133 | -------------------------------------------------------------------------------- /demo/entry-client.ts: -------------------------------------------------------------------------------- 1 | import createApp from './main'; 2 | import { removeCssHotReloaded } from './collect-css-ssr'; 3 | 4 | removeCssHotReloaded(); 5 | 6 | const { app, router } = createApp(); 7 | 8 | // wait until router is ready before mounting to ensure hydration match 9 | // eslint-disable-next-line no-void 10 | void router.isReady().then(() => { 11 | app.mount('#app'); 12 | }); 13 | -------------------------------------------------------------------------------- /demo/entry-server.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import type { ModuleNode, ViteDevServer } from 'vite'; 3 | import { renderToString, type SSRContext } from 'vue/server-renderer'; 4 | import { collectCss } from './collect-css-ssr'; 5 | import createApp from './main'; 6 | 7 | export default async function render( 8 | url: string, 9 | { moduleGraph }: ViteDevServer 10 | ) { 11 | const { app, router } = createApp(); 12 | 13 | // set the router to the desired URL before rendering 14 | // eslint-disable-next-line no-void 15 | void router.push(url); 16 | await router.isReady(); 17 | 18 | // passing SSR context object which will be available via useSSRContext() 19 | // @vitejs/plugin-vue injects code into a component's setup() that registers 20 | // itself on ctx.modules. After the render, ctx.modules would contain all the 21 | // components that have been instantiated during this render call. 22 | const ctx = {} as SSRContext; 23 | const appHtml = await renderToString(app, ctx); 24 | 25 | const mods: Set = new Set(); 26 | 27 | // add main module for direct imported styles 28 | let mod = moduleGraph.getModuleById(path.resolve('./demo/main.ts')); 29 | 30 | if (mod) { 31 | mods.add(mod); 32 | } 33 | 34 | // add modules from rendered Vue components 35 | (ctx.modules as Set).forEach((componentPath) => { 36 | mod = moduleGraph.getModuleById(path.resolve(componentPath)); 37 | 38 | if (mod) { 39 | mods.add(mod); 40 | } 41 | }); 42 | 43 | return { appHtml, css: collectCss(mods) }; 44 | } 45 | -------------------------------------------------------------------------------- /demo/main.ts: -------------------------------------------------------------------------------- 1 | import { createSSRApp } from 'vue'; 2 | import App from './App.vue'; 3 | import createRouter from './router'; 4 | import './assets/main.scss'; 5 | 6 | // SSR requires a fresh app instance per request, therefore we export a function 7 | // that creates a fresh app instance. If using Vuex, we'd also be creating a 8 | // fresh store here. 9 | export default function createApp() { 10 | const app = createSSRApp(App); 11 | 12 | const router = createRouter(); 13 | 14 | app.use(router); 15 | 16 | return { app, router }; 17 | } 18 | -------------------------------------------------------------------------------- /demo/router.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createRouter as _createRouter, 3 | createMemoryHistory, 4 | createWebHistory, 5 | } from 'vue-router'; 6 | 7 | import routes from './routes'; 8 | 9 | export default function createRouter() { 10 | return _createRouter({ 11 | // use appropriate history implementation for server/client 12 | // import.meta.env.SSR is injected by Vite. 13 | history: import.meta.env.SSR ? createMemoryHistory() : createWebHistory(), 14 | routes, 15 | }); 16 | } 17 | -------------------------------------------------------------------------------- /demo/routes.ts: -------------------------------------------------------------------------------- 1 | export default [ 2 | { 3 | path: '/component/LazyHydrationWrapper', 4 | component: () => import('./views/component/LazyHydrationWrapperDemo.vue'), 5 | alias: ['/', '/component'], 6 | }, 7 | { 8 | path: '/composables/LazyHydration', 9 | component: () => import('./views/composables/LazyHydrationDemo.vue'), 10 | alias: '/composables', 11 | }, 12 | { 13 | path: '/composables/HydrateWhenIdle', 14 | component: () => import('./views/composables/HydrateWhenIdleDemo.vue'), 15 | }, 16 | { 17 | path: '/composables/HydrateWhenVisible', 18 | component: () => import('./views/composables/HydrateWhenVisibleDemo.vue'), 19 | }, 20 | { 21 | path: '/composables/HydrateWhenTriggered', 22 | component: () => import('./views/composables/HydrateWhenTriggeredDemo.vue'), 23 | }, 24 | { 25 | path: '/composables/HydrateOnInteraction', 26 | component: () => import('./views/composables/HydrateOnInteractionDemo.vue'), 27 | }, 28 | { 29 | path: '/import-wrappers/HydrateNever', 30 | component: () => import('./views/import-wrappers/HydrateNeverDemo.vue'), 31 | alias: '/import-wrappers', 32 | }, 33 | { 34 | path: '/import-wrappers/HydrateWhenIdle', 35 | component: () => import('./views/import-wrappers/HydrateWhenIdleDemo.vue'), 36 | }, 37 | { 38 | path: '/import-wrappers/HydrateWhenVisible', 39 | component: () => 40 | import('./views/import-wrappers/HydrateWhenVisibleDemo.vue'), 41 | }, 42 | { 43 | path: '/import-wrappers/HydrateWhenTriggered', 44 | component: () => 45 | import('./views/import-wrappers/HydrateWhenTriggeredDemo.vue'), 46 | }, 47 | { 48 | path: '/import-wrappers/HydrateOnInteraction', 49 | component: () => 50 | import('./views/import-wrappers/HydrateOnInteractionDemo.vue'), 51 | }, 52 | ]; 53 | -------------------------------------------------------------------------------- /demo/views/component/LazyHydrationWrapperDemo.vue: -------------------------------------------------------------------------------- 1 | 68 | 69 | 140 | 141 | 146 | -------------------------------------------------------------------------------- /demo/views/composables/HydrateOnInteractionDemo.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 48 | -------------------------------------------------------------------------------- /demo/views/composables/HydrateWhenIdleDemo.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 22 | -------------------------------------------------------------------------------- /demo/views/composables/HydrateWhenTriggeredDemo.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 38 | -------------------------------------------------------------------------------- /demo/views/composables/HydrateWhenVisibleDemo.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 30 | -------------------------------------------------------------------------------- /demo/views/composables/LazyHydrationDemo.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 22 | -------------------------------------------------------------------------------- /demo/views/import-wrappers/HydrateNeverDemo.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 27 | -------------------------------------------------------------------------------- /demo/views/import-wrappers/HydrateOnInteractionDemo.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 53 | -------------------------------------------------------------------------------- /demo/views/import-wrappers/HydrateWhenIdleDemo.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 27 | -------------------------------------------------------------------------------- /demo/views/import-wrappers/HydrateWhenTriggeredDemo.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 40 | -------------------------------------------------------------------------------- /demo/views/import-wrappers/HydrateWhenVisibleDemo.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 35 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vue3 Lazy Hydration Demo 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue3-lazy-hydration", 3 | "version": "1.2.1", 4 | "description": "Lazy Hydration for Vue 3 SSR", 5 | "keywords": [ 6 | "vue", 7 | "vue3", 8 | "lazy", 9 | "hydration", 10 | "ssr" 11 | ], 12 | "author": "freddy38510 ", 13 | "homepage": "https://github.com/freddy38510/vue3-lazy-hydration", 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/freddy38510/vue3-lazy-hydration" 17 | }, 18 | "bugs": { 19 | "url": "https://github.com/freddy38510/vue3-lazy-hydration/issues" 20 | }, 21 | "license": "MIT", 22 | "files": [ 23 | "dist" 24 | ], 25 | "type": "module", 26 | "main": "./dist/vue3-lazy-hydration.cjs", 27 | "module": "./dist/esm/vue3-lazy-hydration.mjs", 28 | "exports": { 29 | ".": { 30 | "types": "./dist/vue3-lazy-hydration.d.ts", 31 | "import": "./dist/esm/vue3-lazy-hydration.mjs", 32 | "require": "./dist/vue3-lazy-hydration.cjs" 33 | } 34 | }, 35 | "types": "./dist/vue3-lazy-hydration.d.ts", 36 | "engines": { 37 | "node": ">=14.6" 38 | }, 39 | "sideEffects": false, 40 | "config": { 41 | "commitizen": { 42 | "path": "cz-conventional-changelog" 43 | } 44 | }, 45 | "scripts": { 46 | "build": "rimraf dist && vite build && pnpm build-temp-types ", 47 | "build-temp-types": "tsc --emitDeclarationOnly -p ./tsconfig.types.json && api-extractor run && rimraf temp", 48 | "dev": "rimraf dist && vite", 49 | "test:unit": "vitest", 50 | "coverage": "vitest run --coverage", 51 | "format": "prettier --write --cache .", 52 | "lint": "eslint --cache ." 53 | }, 54 | "peerDependencies": { 55 | "vue": ">=3.0.0" 56 | }, 57 | "devDependencies": { 58 | "@commitlint/cli": "17.2.0", 59 | "@commitlint/config-conventional": "17.2.0", 60 | "@microsoft/api-extractor": "7.33.6", 61 | "@rushstack/eslint-patch": "1.2.0", 62 | "@types/node": "18.11.9", 63 | "@vitejs/plugin-vue": "3.2.0", 64 | "@vitest/coverage-c8": "^0.25.0", 65 | "@vitest/ui": "0.25.2", 66 | "@vue/eslint-config-airbnb-with-typescript": "7.0.0", 67 | "@vue/server-renderer": "3.2.45", 68 | "@vue/test-utils": "2.2.3", 69 | "@vue/tsconfig": "0.1.3", 70 | "commitizen": "4.2.5", 71 | "cz-conventional-changelog": "3.3.0", 72 | "eslint": "8.27.0", 73 | "eslint-config-prettier": "8.5.0", 74 | "eslint-define-config": "1.12.0", 75 | "eslint-plugin-import": "2.26.0", 76 | "eslint-plugin-vitest-globals": "1.2.0", 77 | "eslint-plugin-vue": "9.7.0", 78 | "happy-dom": "7.7.0", 79 | "lint-staged": "13.0.3", 80 | "pnpm": "7.33.4", 81 | "prettier": "2.7.1", 82 | "rimraf": "3.0.2", 83 | "sass": "1.56.1", 84 | "simple-git-hooks": "2.8.1", 85 | "typescript": "4.9.3", 86 | "vite": "3.2.7", 87 | "vitest": "0.25.2", 88 | "vue": "3.2.45", 89 | "vue-router": "4.1.6" 90 | }, 91 | "simple-git-hooks": { 92 | "pre-commit": "pnpm lint-staged --concurrent false", 93 | "prepare-commit-msg": "cat $1 | npx commitlint -q || (exec < /dev/tty && node_modules/.bin/cz --hook || true)", 94 | "commit-msg": "npx --no -- commitlint --edit $1" 95 | }, 96 | "lint-staged": { 97 | "*": [ 98 | "prettier --write --cache --ignore-unknown" 99 | ], 100 | "src/**/*.ts": [ 101 | "eslint --cache --fix" 102 | ] 103 | }, 104 | "packageManager": "pnpm@7.16.1" 105 | } 106 | -------------------------------------------------------------------------------- /plugin-dev-server.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import type { IncomingMessage, ServerResponse } from 'node:http'; 3 | import type { Connect, Plugin, ViteDevServer } from 'vite'; 4 | import type TRender from './demo/entry-server'; 5 | 6 | async function ssrMiddleware( 7 | vite: ViteDevServer, 8 | req: Connect.IncomingMessage, 9 | res: ServerResponse 10 | ) { 11 | const template = await vite.transformIndexHtml( 12 | req.url as string, 13 | fs.readFileSync(`${__dirname}/index.html`, 'utf8'), 14 | req.originalUrl 15 | ); 16 | 17 | const render = (await vite.ssrLoadModule('/demo/entry-server.ts')) 18 | .default as typeof TRender; 19 | 20 | const { appHtml, css } = await render(req.originalUrl as string, vite); 21 | 22 | res.statusCode = 200; 23 | 24 | res.setHeader('content-type', 'text/html'); 25 | 26 | res.end( 27 | template 28 | .replace('', css) 29 | .replace('', appHtml) 30 | ); 31 | } 32 | 33 | export default function devSSRPlugin(): Plugin { 34 | return { 35 | name: 'dev-ssr', 36 | configureServer(vite) { 37 | const { logger } = vite.config; 38 | 39 | return () => 40 | vite.middlewares.use((req, res, next) => { 41 | ssrMiddleware(vite, req, res).catch((e: Error) => { 42 | vite.ssrFixStacktrace(e); 43 | 44 | logger.error(e.stack || e.message); 45 | 46 | next(e); 47 | }); 48 | }); 49 | }, 50 | }; 51 | } 52 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freddy38510/vue3-lazy-hydration/1550633c47bf726e18510299fb1e9ed9bfb7e249/public/favicon.ico -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["config:recommended", "schedule:weekly", "group:allNonMajor"], 4 | "labels": ["dependencies"] 5 | } 6 | -------------------------------------------------------------------------------- /src/components/LazyHydrationWrapper.spec.ts: -------------------------------------------------------------------------------- 1 | import { createSSRApp, h, nextTick, ref, type VNode } from 'vue'; 2 | import { renderToString } from '@vue/server-renderer'; 3 | import { flushPromises } from '@vue/test-utils'; 4 | 5 | import { 6 | ensureMocksReset, 7 | requestIdleCallback, 8 | intersectionObserver, 9 | } from '../../test/dom-mocks'; 10 | import { triggerEvent } from '../../test/utils'; 11 | 12 | import LazyHydrationWrapper from './LazyHydrationWrapper'; 13 | 14 | function mountWithHydration(html: string, template: VNode) { 15 | const app = createSSRApp({ 16 | render: () => template, 17 | }); 18 | 19 | const container = document.createElement('div'); 20 | container.innerHTML = html; 21 | 22 | return { 23 | vnode: app.mount(container).$.subTree, 24 | container, 25 | }; 26 | } 27 | 28 | beforeEach(() => { 29 | document.body.innerHTML = ''; 30 | }); 31 | 32 | afterEach(() => { 33 | ensureMocksReset(); 34 | }); 35 | 36 | it('should hydrate on Interaction', async () => { 37 | const spy = vi.fn(); 38 | 39 | const { vnode, container } = mountWithHydration( 40 | '', 41 | h(LazyHydrationWrapper, { onInteraction: ['focus'] }, () => 42 | h('button', { onClick: spy }, 'foo') 43 | ) 44 | ); 45 | 46 | expect(vnode.el).toBe(container.firstChild); 47 | expect(container.textContent).toBe('foo'); 48 | 49 | // hydration not complete yet 50 | triggerEvent('click', container.querySelector('button')); 51 | expect(spy).not.toHaveBeenCalled(); 52 | 53 | // trigger hydration and wait for it to complete 54 | triggerEvent('focus', container.querySelector('button')); 55 | await nextTick(); 56 | 57 | // should be hydrated now 58 | triggerEvent('click', container.querySelector('button')); 59 | expect(spy).toHaveBeenCalled(); 60 | }); 61 | 62 | it('should hydrate when browser is idle', async () => { 63 | requestIdleCallback.mock(); 64 | 65 | const spy = vi.fn(); 66 | 67 | const { vnode, container } = mountWithHydration( 68 | '', 69 | h(LazyHydrationWrapper, { whenIdle: true }, () => 70 | h('button', { onClick: spy }, 'foo') 71 | ) 72 | ); 73 | 74 | expect(vnode.el).toBe(container.firstChild); 75 | expect(container.textContent).toBe('foo'); 76 | 77 | // hydration not complete yet 78 | triggerEvent('click', container.querySelector('button')); 79 | expect(spy).not.toHaveBeenCalled(); 80 | 81 | // trigger hydration and wait for it to complete 82 | requestIdleCallback.runIdleCallbacks(); 83 | await nextTick(); 84 | 85 | // should be hydrated now 86 | triggerEvent('click', container.querySelector('button')); 87 | expect(spy).toHaveBeenCalled(); 88 | 89 | requestIdleCallback.restore(); 90 | }); 91 | 92 | it('should never hydrate', async () => { 93 | const spy = vi.fn(); 94 | 95 | const { vnode, container } = mountWithHydration( 96 | '', 97 | h(LazyHydrationWrapper, () => h('button', { onClick: spy }, 'foo')) 98 | ); 99 | 100 | expect(vnode.el).toBe(container.firstChild); 101 | expect(container.textContent).toBe('foo'); 102 | 103 | // hydration not complete yet 104 | triggerEvent('click', container.querySelector('button')); 105 | expect(spy).not.toHaveBeenCalled(); 106 | 107 | // should not be hydrated 108 | await nextTick(); 109 | triggerEvent('click', container.querySelector('button')); 110 | expect(spy).not.toHaveBeenCalled(); 111 | }); 112 | 113 | it('should hydrate when component element is visible', async () => { 114 | intersectionObserver.mock(); 115 | 116 | const spy = vi.fn(); 117 | 118 | const { vnode, container } = mountWithHydration( 119 | '', 120 | h(LazyHydrationWrapper, { whenVisible: true }, () => 121 | h('button', { onClick: spy }, 'foo') 122 | ) 123 | ); 124 | 125 | expect(vnode.el).toBe(container.firstChild); 126 | expect(container.textContent).toBe('foo'); 127 | 128 | // hydration not complete yet 129 | triggerEvent('click', container.querySelector('button')); 130 | expect(spy).not.toHaveBeenCalled(); 131 | 132 | // trigger hydration and wait for it to complete 133 | intersectionObserver.simulate({ 134 | target: container.querySelector('button')!, 135 | isIntersecting: true, 136 | }); 137 | await nextTick(); 138 | 139 | // should be hydrated now 140 | triggerEvent('click', container.querySelector('button')); 141 | expect(spy).toHaveBeenCalled(); 142 | 143 | intersectionObserver.restore(); 144 | }); 145 | 146 | it('should hydrate when triggered', async () => { 147 | const spy = vi.fn(); 148 | const triggered = ref(false); 149 | 150 | const { vnode, container } = mountWithHydration( 151 | '', 152 | h(LazyHydrationWrapper, { whenTriggered: triggered }, () => 153 | h('button', { onClick: spy }, 'foo') 154 | ) 155 | ); 156 | 157 | expect(vnode.el).toBe(container.firstChild); 158 | expect(container.textContent).toBe('foo'); 159 | 160 | // hydration not complete yet 161 | triggerEvent('click', container.querySelector('button')); 162 | expect(spy).not.toHaveBeenCalled(); 163 | 164 | // trigger hydration and wait for it to complete 165 | triggered.value = true; 166 | await nextTick(); 167 | 168 | // should be hydrated now 169 | triggerEvent('click', container.querySelector('button')); 170 | expect(spy).toHaveBeenCalled(); 171 | }); 172 | 173 | it('should emit hydrated event when component has been hydrated', async () => { 174 | const spyOnClick = vi.fn(); 175 | const spyOnHydratedHook = vi.fn(); 176 | const trigger = ref(false); 177 | 178 | const { vnode, container } = mountWithHydration( 179 | '', 180 | h( 181 | LazyHydrationWrapper, 182 | { whenTriggered: trigger, onHydrated: spyOnHydratedHook }, 183 | () => h('button', { onClick: spyOnClick }, 'foo') 184 | ) 185 | ); 186 | 187 | expect(vnode.el).toBe(container.firstChild); 188 | expect(container.textContent).toBe('foo'); 189 | 190 | // hydration not complete yet 191 | triggerEvent('click', container.querySelector('button')); 192 | expect(spyOnClick).not.toHaveBeenCalled(); 193 | expect(spyOnHydratedHook).not.toHaveBeenCalled(); 194 | 195 | // trigger hydration and wait for it to complete 196 | trigger.value = true; 197 | await flushPromises(); 198 | 199 | // should be hydrated now 200 | triggerEvent('click', container.querySelector('button')); 201 | expect(spyOnClick).toHaveBeenCalled(); 202 | expect(spyOnHydratedHook).toHaveBeenCalledOnce(); 203 | }); 204 | 205 | it('should hydrate when browser is idle (full integration)', async () => { 206 | requestIdleCallback.mock(); 207 | 208 | const spy = vi.fn(); 209 | const Comp = () => 210 | h( 211 | 'button', 212 | { 213 | onClick: spy, 214 | }, 215 | 'hello!' 216 | ); 217 | 218 | const App = { 219 | render() { 220 | return h(LazyHydrationWrapper, { whenIdle: true }, Comp); 221 | }, 222 | }; 223 | 224 | const container = document.createElement('div'); 225 | // server render 226 | container.innerHTML = await renderToString(h(App)); 227 | // hydrate app 228 | createSSRApp(App).mount(container); 229 | 230 | // hydration not complete yet 231 | triggerEvent('click', container.querySelector('button')); 232 | expect(spy).not.toHaveBeenCalled(); 233 | 234 | // trigger hydration and wait for it to complete 235 | requestIdleCallback.runIdleCallbacks(); 236 | await nextTick(); 237 | 238 | // should be hydrated now 239 | triggerEvent('click', container.querySelector('button')); 240 | expect(spy).toHaveBeenCalled(); 241 | 242 | requestIdleCallback.restore(); 243 | }); 244 | 245 | it('should never hydrate (full integration)', async () => { 246 | const spy = vi.fn(); 247 | 248 | const Comp = () => 249 | h( 250 | 'button', 251 | { 252 | onClick: spy, 253 | }, 254 | 'hello!' 255 | ); 256 | 257 | const App = { 258 | render() { 259 | return h(LazyHydrationWrapper, Comp); 260 | }, 261 | }; 262 | 263 | const container = document.createElement('div'); 264 | // server render 265 | container.innerHTML = await renderToString(h(App)); 266 | // hydrate app 267 | createSSRApp(App).mount(container); 268 | 269 | // hydration not complete yet 270 | triggerEvent('click', container.querySelector('button')); 271 | expect(spy).not.toHaveBeenCalled(); 272 | 273 | // should not be hydrated 274 | await nextTick(); 275 | triggerEvent('click', container.querySelector('button')); 276 | expect(spy).not.toHaveBeenCalled(); 277 | }); 278 | 279 | it('should hydrate when component element is visible (full integration)', async () => { 280 | intersectionObserver.mock(); 281 | 282 | const spy = vi.fn(); 283 | 284 | const Comp = () => 285 | h( 286 | 'button', 287 | { 288 | onClick: spy, 289 | }, 290 | 'hello!' 291 | ); 292 | 293 | const App = { 294 | render() { 295 | return h(LazyHydrationWrapper, { whenVisible: true }, Comp); 296 | }, 297 | }; 298 | 299 | const container = document.createElement('div'); 300 | // server render 301 | container.innerHTML = await renderToString(h(App)); 302 | // hydrate app 303 | createSSRApp(App).mount(container); 304 | 305 | // hydration not complete yet 306 | triggerEvent('click', container.querySelector('button')); 307 | expect(spy).not.toHaveBeenCalled(); 308 | 309 | // trigger hydration and wait for it to complete 310 | intersectionObserver.simulate({ 311 | target: container.querySelector('button')!, 312 | isIntersecting: true, 313 | }); 314 | await nextTick(); 315 | 316 | // should be hydrated now 317 | triggerEvent('click', container.querySelector('button')); 318 | expect(spy).toHaveBeenCalled(); 319 | 320 | intersectionObserver.restore(); 321 | }); 322 | 323 | it('should hydrate when triggered (full integration)', async () => { 324 | intersectionObserver.mock(); 325 | 326 | const spy = vi.fn(); 327 | 328 | const Comp = () => 329 | h( 330 | 'button', 331 | { 332 | onClick: spy, 333 | }, 334 | 'hello!' 335 | ); 336 | 337 | const trigger = ref(false); 338 | 339 | const App = { 340 | render() { 341 | return h(LazyHydrationWrapper, { whenTriggered: trigger }, Comp); 342 | }, 343 | }; 344 | 345 | const container = document.createElement('div'); 346 | // server render 347 | container.innerHTML = await renderToString(h(App)); 348 | // hydrate app 349 | createSSRApp(App).mount(container); 350 | 351 | // hydration not complete yet 352 | triggerEvent('click', container.querySelector('button')); 353 | expect(spy).not.toHaveBeenCalled(); 354 | 355 | // trigger hydration and wait for it to complete 356 | trigger.value = true; 357 | await nextTick(); 358 | 359 | // should be hydrated now 360 | triggerEvent('click', container.querySelector('button')); 361 | expect(spy).toHaveBeenCalled(); 362 | 363 | intersectionObserver.restore(); 364 | }); 365 | 366 | it('should emit hydrated event when component has been hydrated (full integration)', async () => { 367 | const spyOnClick = vi.fn(); 368 | const spyOnHydratedHook = vi.fn(); 369 | const triggered = ref(false); 370 | 371 | const Comp = () => 372 | h( 373 | 'button', 374 | { 375 | onClick: spyOnClick, 376 | }, 377 | 'hello!' 378 | ); 379 | 380 | const App = { 381 | render() { 382 | return h( 383 | LazyHydrationWrapper, 384 | { whenTriggered: triggered, onHydrated: spyOnHydratedHook }, 385 | Comp 386 | ); 387 | }, 388 | }; 389 | 390 | const container = document.createElement('div'); 391 | // server render 392 | container.innerHTML = await renderToString(h(App)); 393 | // hydrate app 394 | createSSRApp(App).mount(container); 395 | 396 | // hydration not complete yet 397 | triggerEvent('click', container.querySelector('button')); 398 | expect(spyOnClick).not.toHaveBeenCalled(); 399 | 400 | // trigger hydration and wait for it to complete 401 | triggered.value = true; 402 | await flushPromises(); 403 | 404 | // should be hydrated now 405 | triggerEvent('click', container.querySelector('button')); 406 | expect(spyOnClick).toHaveBeenCalled(); 407 | expect(spyOnHydratedHook).toHaveBeenCalledOnce(); 408 | }); 409 | -------------------------------------------------------------------------------- /src/components/LazyHydrationWrapper.ts: -------------------------------------------------------------------------------- 1 | import { computed, defineComponent, markRaw, toRef, type VNode } from 'vue'; 2 | 3 | import { 4 | useLazyHydration, 5 | useHydrateWhenIdle, 6 | useHydrateWhenVisible, 7 | useHydrateOnInteraction, 8 | useHydrateWhenTriggered, 9 | } from '../composables'; 10 | 11 | const normalizeSlot = (slotContent: VNode[]) => 12 | slotContent.length === 1 ? slotContent[0] : slotContent; 13 | 14 | const LazyHydrationWrapper = defineComponent({ 15 | name: 'LazyHydrationWrapper', 16 | inheritAttrs: false, 17 | suspensible: false, 18 | props: { 19 | whenIdle: { 20 | default: false, 21 | type: [Boolean, Number], 22 | }, 23 | whenVisible: { 24 | default: false, 25 | type: [Boolean, Object], 26 | }, 27 | onInteraction: { 28 | default: false, 29 | type: [Array, Boolean, String], 30 | }, 31 | whenTriggered: { 32 | default: undefined, 33 | type: [Boolean, Object], 34 | }, 35 | }, 36 | emits: ['hydrated'], 37 | 38 | setup(props, { slots, emit }) { 39 | const result = useLazyHydration(); 40 | 41 | if (!result.willPerformHydration) { 42 | return () => normalizeSlot(slots.default!()); 43 | } 44 | 45 | result.onHydrated(() => emit('hydrated')); 46 | 47 | if (props.whenIdle) { 48 | useHydrateWhenIdle( 49 | result, 50 | props.whenIdle !== true ? props.whenIdle : undefined 51 | ); 52 | } 53 | 54 | if (props.whenVisible) { 55 | useHydrateWhenVisible( 56 | result, 57 | props.whenVisible !== true ? props.whenVisible : undefined 58 | ); 59 | } 60 | 61 | if (props.onInteraction) { 62 | let events; 63 | 64 | if (props.onInteraction !== true) { 65 | events = computed(() => 66 | Array.isArray(props.onInteraction) 67 | ? props.onInteraction 68 | : [props.onInteraction] 69 | ).value; 70 | } 71 | 72 | useHydrateOnInteraction(result, events); 73 | } 74 | 75 | if (props.whenTriggered !== undefined) { 76 | useHydrateWhenTriggered(result, toRef(props, 'whenTriggered')); 77 | } 78 | 79 | return () => normalizeSlot(slots.default!()); 80 | }, 81 | }); 82 | 83 | /** 84 | * @public A renderless Vue.js component to lazy hydrate its children. 85 | */ 86 | export default markRaw(LazyHydrationWrapper) as typeof LazyHydrationWrapper; 87 | -------------------------------------------------------------------------------- /src/composables/index.ts: -------------------------------------------------------------------------------- 1 | export { default as useLazyHydration } from './useLazyHydration'; 2 | export { default as useHydrateWhenIdle } from './useHydrateWhenIdle'; 3 | export { default as useHydrateOnInteraction } from './useHydrateOnInteraction'; 4 | export { default as useHydrateWhenTriggered } from './useHydrateWhenTriggered'; 5 | export { default as useHydrateWhenVisible } from './useHydrateWhenVisible'; 6 | -------------------------------------------------------------------------------- /src/composables/useHydrateOnInteraction.spec.ts: -------------------------------------------------------------------------------- 1 | import { h, onMounted, ref } from 'vue'; 2 | import { flushPromises } from '@vue/test-utils'; 3 | 4 | import { withSSRSetup, triggerEvent, createApp } from '../../test/utils'; 5 | 6 | import { useLazyHydration, useHydrateOnInteraction } from '.'; 7 | 8 | beforeEach(() => { 9 | document.body.innerHTML = ''; 10 | }); 11 | 12 | it('should hydrate on interaction with single root element', async () => { 13 | const spyClick = vi.fn(); 14 | 15 | const { container } = await withSSRSetup(() => { 16 | const result = useLazyHydration(); 17 | 18 | useHydrateOnInteraction(result, ['focus']); 19 | 20 | return () => h('button', { onClick: spyClick }, 'foo'); 21 | }); 22 | 23 | // hydration not complete yet 24 | triggerEvent('click', container.querySelector('button')); 25 | expect(spyClick).not.toHaveBeenCalled(); 26 | 27 | // trigger hydration and wait for it to complete 28 | triggerEvent('focus', container.querySelector('button')); 29 | await flushPromises(); 30 | 31 | // should be hydrated now 32 | triggerEvent('click', container.querySelector('button')); 33 | expect(spyClick).toHaveBeenCalledOnce(); 34 | }); 35 | 36 | it('should hydrate on interaction with multiple root elements', async () => { 37 | const spyClick = vi.fn(); 38 | 39 | const { container } = await withSSRSetup(() => { 40 | const result = useLazyHydration(); 41 | 42 | useHydrateOnInteraction(result, ['click', 'focus']); 43 | 44 | return () => [ 45 | h('div', h('button', { onClick: spyClick }, 'foo')), 46 | h('p', 'bar'), 47 | ]; 48 | }); 49 | 50 | // hydration not complete yet 51 | triggerEvent('click', container.querySelector('button')); 52 | expect(spyClick).not.toHaveBeenCalled(); 53 | 54 | // trigger hydration and wait for it to complete 55 | triggerEvent('focus', container.querySelector('p')); 56 | await flushPromises(); 57 | 58 | // should be hydrated now 59 | triggerEvent('click', container.querySelector('button')); 60 | expect(spyClick).toHaveBeenCalledOnce(); 61 | }); 62 | 63 | it('should remove listeners when component has been hydrated', async () => { 64 | const spyClick = vi.fn(); 65 | 66 | const { container } = await withSSRSetup(() => { 67 | const result = useLazyHydration(); 68 | 69 | useHydrateOnInteraction(result, ['focus', 'select']); 70 | 71 | return () => h('button', { onClick: spyClick }, 'foo'); 72 | }); 73 | 74 | const spyRemoveEventListener = vi.spyOn( 75 | container.querySelector('button') as Element, 76 | 'removeEventListener' 77 | ); 78 | 79 | // hydration not complete yet 80 | triggerEvent('click', container.querySelector('button')); 81 | expect(spyClick).not.toHaveBeenCalled(); 82 | 83 | // trigger hydration and wait for it to complete 84 | triggerEvent('focus', container.querySelector('button')); 85 | await flushPromises(); 86 | 87 | // should be hydrated now 88 | triggerEvent('click', container.querySelector('button')); 89 | expect(spyClick).toHaveBeenCalledOnce(); 90 | expect(spyRemoveEventListener).toHaveBeenCalledTimes(2); 91 | }); 92 | 93 | it('should remove listeners when component has been unmounted', async () => { 94 | const show = ref(true); 95 | 96 | const { container } = await withSSRSetup(() => { 97 | const LazyComp = { 98 | setup() { 99 | const result = useLazyHydration(); 100 | 101 | useHydrateOnInteraction(result, ['focus', 'select']); 102 | 103 | return () => h('button', 'foo'); 104 | }, 105 | }; 106 | 107 | return () => h('div', [show.value ? h(LazyComp) : h('div', 'hi')]); 108 | }); 109 | 110 | const spyRemoveEventListener = vi.spyOn( 111 | container.querySelector('button') as Element, 112 | 'removeEventListener' 113 | ); 114 | 115 | // trigger onUnmounted hook 116 | show.value = false; 117 | 118 | await flushPromises(); 119 | 120 | expect(spyRemoveEventListener).toHaveBeenCalledTimes(2); 121 | }); 122 | 123 | it('should hydrate on interaction when composedPath API is not supported', async () => { 124 | const spyClick = vi.fn(); 125 | const triggerLegacyEvent = (type: string, el: Element) => { 126 | const event: (Omit & Partial) & { 127 | path?: EventTarget; 128 | } = new Event(type, { bubbles: true }); 129 | 130 | event.path = undefined; 131 | event.composedPath = undefined; 132 | 133 | el.dispatchEvent(event as Event); 134 | }; 135 | 136 | const { container } = await withSSRSetup(() => { 137 | const result = useLazyHydration(); 138 | 139 | useHydrateOnInteraction(result, ['focus']); 140 | 141 | return () => h('div', h('button', { onClick: spyClick }, 'foo')); 142 | }); 143 | 144 | // hydration not complete yet 145 | triggerLegacyEvent('click', container.querySelector('button') as Element); 146 | expect(spyClick).not.toHaveBeenCalled(); 147 | 148 | // trigger hydration and wait for it to complete 149 | triggerLegacyEvent('focus', container.querySelector('button') as Element); 150 | await flushPromises(); 151 | 152 | // should be hydrated now 153 | triggerLegacyEvent('click', container.querySelector('button') as Element); 154 | expect(spyClick).toHaveBeenCalledOnce(); 155 | }); 156 | 157 | it('should throw error when used outside of the setup method', async () => { 158 | const handler = vi.fn(); 159 | const err = new Error( 160 | 'useHydrateOnInteraction must be called from the setup method.' 161 | ); 162 | 163 | const container = document.createElement('div'); 164 | container.innerHTML = 'foo'; 165 | document.append(container); 166 | 167 | const app = createApp(() => { 168 | const result = useLazyHydration(); 169 | 170 | onMounted(() => { 171 | useHydrateOnInteraction(result); 172 | }); 173 | 174 | return () => 'foo'; 175 | }); 176 | 177 | app.config.errorHandler = handler; 178 | 179 | app.mount(container); 180 | 181 | expect(handler).toHaveBeenCalled(); 182 | expect(handler.mock.calls[0][0]).toStrictEqual(err); 183 | }); 184 | -------------------------------------------------------------------------------- /src/composables/useHydrateOnInteraction.ts: -------------------------------------------------------------------------------- 1 | import { getCurrentInstance, onMounted, unref } from 'vue'; 2 | import getRootElements from '../utils/get-root-elements'; 3 | import type useLazyHydration from './useLazyHydration'; 4 | 5 | /** 6 | * @public A Vue.js composable to delay hydration until a specified HTML event occurs on any component element. 7 | */ 8 | export default function useHydrateOnInteraction( 9 | { 10 | willPerformHydration, 11 | hydrate, 12 | onCleanup, 13 | }: ReturnType, 14 | events: (keyof HTMLElementEventMap)[] = ['focus'] 15 | ) { 16 | if (!willPerformHydration) { 17 | return; 18 | } 19 | 20 | const instance = getCurrentInstance(); 21 | 22 | if (!instance || instance.isMounted) { 23 | throw new Error( 24 | 'useHydrateOnInteraction must be called from the setup method.' 25 | ); 26 | } 27 | 28 | const eventsTypes = unref(events); 29 | 30 | onMounted(() => { 31 | const targets = getRootElements(instance); 32 | 33 | // container is the single root element or the parent element of the multiple root elements 34 | const container: Element = 35 | targets.length > 1 ? targets[0].parentElement || document : targets[0]; 36 | 37 | const eventListenerOptions = { 38 | capture: true, 39 | once: false, 40 | passive: true, 41 | }; 42 | 43 | const listener = (event: Event & { path?: EventTarget }) => { 44 | event.stopPropagation(); 45 | 46 | const paths = (event.composedPath && event.composedPath()) || event.path; 47 | 48 | if (!paths) { 49 | let el = event.target as HTMLElement | null; 50 | 51 | while (el) { 52 | if (targets.includes(el)) { 53 | hydrate(); 54 | 55 | return; 56 | } 57 | 58 | if (el === container) { 59 | return; 60 | } 61 | 62 | el = el.parentElement; 63 | } 64 | 65 | return; 66 | } 67 | 68 | targets.forEach((target) => { 69 | if (paths.includes(target as EventTarget)) { 70 | hydrate(); 71 | } 72 | }); 73 | }; 74 | 75 | eventsTypes.forEach((eventType) => { 76 | container.addEventListener(eventType, listener, eventListenerOptions); 77 | }); 78 | 79 | onCleanup(() => { 80 | eventsTypes.forEach((eventType) => { 81 | container.removeEventListener( 82 | eventType, 83 | listener, 84 | eventListenerOptions 85 | ); 86 | }); 87 | }); 88 | }); 89 | } 90 | -------------------------------------------------------------------------------- /src/composables/useHydrateWhenIdle.spec.ts: -------------------------------------------------------------------------------- 1 | import { createSSRApp, h, ref } from 'vue'; 2 | import { renderToString } from '@vue/server-renderer'; 3 | import { flushPromises } from '@vue/test-utils'; 4 | 5 | import { ensureMocksReset, requestIdleCallback } from '../../test/dom-mocks'; 6 | import { withSSRSetup, triggerEvent } from '../../test/utils'; 7 | 8 | import { useLazyHydration, useHydrateWhenIdle } from '.'; 9 | 10 | beforeEach(() => { 11 | document.body.innerHTML = ''; 12 | }); 13 | 14 | afterEach(() => { 15 | ensureMocksReset(); 16 | }); 17 | 18 | it('should hydrate when idle', async () => { 19 | requestIdleCallback.mock(); 20 | const spyClick = vi.fn(); 21 | 22 | const { container } = await withSSRSetup(() => { 23 | const result = useLazyHydration(); 24 | 25 | useHydrateWhenIdle(result); 26 | 27 | return () => h('button', { onClick: spyClick }, 'foo'); 28 | }); 29 | 30 | // hydration not complete yet 31 | triggerEvent('click', container.querySelector('button')); 32 | expect(spyClick).not.toHaveBeenCalled(); 33 | 34 | // trigger hydration and wait for it to complete 35 | requestIdleCallback.runIdleCallbacks(); 36 | await flushPromises(); 37 | 38 | // should be hydrated now 39 | triggerEvent('click', container.querySelector('button')); 40 | expect(spyClick).toHaveBeenCalledOnce(); 41 | 42 | requestIdleCallback.restore(); 43 | }); 44 | 45 | it('should cancel Idle Callback when component has been hydrated', async () => { 46 | requestIdleCallback.mock(); 47 | const spyCancelIdleCallback = vi.spyOn(window, 'cancelIdleCallback'); 48 | 49 | await withSSRSetup(() => { 50 | const result = useLazyHydration(); 51 | 52 | useHydrateWhenIdle(result); 53 | 54 | return () => h('button', 'foo'); 55 | }); 56 | 57 | // trigger hydration and wait for it to complete 58 | requestIdleCallback.runIdleCallbacks(); 59 | await flushPromises(); 60 | 61 | // should be hydrated now 62 | expect(spyCancelIdleCallback).toHaveBeenCalledOnce(); 63 | 64 | requestIdleCallback.restore(); 65 | }); 66 | 67 | it('should cancel Idle Callback when component has been unmounted', async () => { 68 | requestIdleCallback.mock(); 69 | const spyCancelIdleCallback = vi.spyOn(window, 'cancelIdleCallback'); 70 | 71 | const show = ref(true); 72 | 73 | await withSSRSetup(() => { 74 | const LazyComp = { 75 | setup() { 76 | const result = useLazyHydration(); 77 | 78 | useHydrateWhenIdle(result); 79 | 80 | return () => h('button', 'foo'); 81 | }, 82 | }; 83 | 84 | return () => h('div', [show.value ? h(LazyComp) : h('div', 'hi')]); 85 | }); 86 | 87 | // trigger onUnmounted hook 88 | show.value = false; 89 | 90 | await flushPromises(); 91 | 92 | expect(spyCancelIdleCallback).toHaveBeenCalledOnce(); 93 | 94 | requestIdleCallback.restore(); 95 | }); 96 | 97 | it('should hydrate when requestIdleCallback is unsupported', async () => { 98 | requestIdleCallback.mockAsUnsupported(); 99 | 100 | const spyClick = vi.fn(); 101 | 102 | const { container } = await withSSRSetup(() => { 103 | const result = useLazyHydration(); 104 | 105 | useHydrateWhenIdle(result); 106 | 107 | return () => h('button', { onClick: spyClick }, 'foo'); 108 | }); 109 | 110 | expect(window.requestIdleCallback).toBeUndefined(); 111 | 112 | // should be hydrated now 113 | triggerEvent('click', container.querySelector('button')); 114 | expect(spyClick).toHaveBeenCalled(); 115 | 116 | requestIdleCallback.restore(); 117 | }); 118 | 119 | it('should throw error when used outside of the setup or lifecycle hook method', async () => { 120 | const originalWindow = window; 121 | const handler = vi.fn(); 122 | const err = new Error( 123 | 'useHydrateWhenIdle must be called from the setup or lifecycle hook methods.' 124 | ); 125 | 126 | let result: ReturnType; 127 | 128 | const LazyComp = { 129 | setup() { 130 | result = useLazyHydration(); 131 | 132 | return () => h('foo'); 133 | }, 134 | }; 135 | 136 | const App = { 137 | setup() { 138 | function onClick() { 139 | useHydrateWhenIdle(result); 140 | } 141 | 142 | return () => [h('button', { onClick }, 'foo'), h(LazyComp)]; 143 | }, 144 | }; 145 | 146 | const app = createSSRApp(App); 147 | 148 | // make sure window is undefined at server-side 149 | vi.stubGlobal('window', undefined); 150 | 151 | // render at server-side 152 | const html = await renderToString(app); 153 | 154 | // restore window for client-side 155 | vi.stubGlobal('window', originalWindow); 156 | 157 | const container = document.createElement('div'); 158 | 159 | container.innerHTML = html; 160 | 161 | document.append(container); 162 | 163 | app.config.errorHandler = handler; 164 | 165 | // hydrate application 166 | app.mount(container); 167 | 168 | // trigger error 169 | triggerEvent('click', container.querySelector('button')); 170 | 171 | expect(handler).toHaveBeenCalled(); 172 | expect(handler.mock.calls[0][0]).toStrictEqual(err); 173 | }); 174 | -------------------------------------------------------------------------------- /src/composables/useHydrateWhenIdle.ts: -------------------------------------------------------------------------------- 1 | import { getCurrentInstance } from 'vue'; 2 | import type useLazyHydration from './useLazyHydration'; 3 | 4 | /** 5 | * @public A Vue.js composable to delay hydration until the browser is idle. 6 | */ 7 | export default function useHydrateWhenIdle( 8 | { 9 | willPerformHydration, 10 | hydrate, 11 | onCleanup, 12 | }: ReturnType, 13 | timeout = 2000 14 | ) { 15 | if (!willPerformHydration) { 16 | return; 17 | } 18 | 19 | if (!getCurrentInstance()) { 20 | throw new Error( 21 | 'useHydrateWhenIdle must be called from the setup or lifecycle hook methods.' 22 | ); 23 | } 24 | 25 | // If `requestIdleCallback()` is not supported, hydrate immediately. 26 | if (!('requestIdleCallback' in window)) { 27 | hydrate(); 28 | 29 | return; 30 | } 31 | 32 | const idleId = requestIdleCallback( 33 | () => { 34 | hydrate(); 35 | }, 36 | { timeout } 37 | ); 38 | 39 | onCleanup(() => { 40 | cancelIdleCallback(idleId); 41 | }); 42 | } 43 | -------------------------------------------------------------------------------- /src/composables/useHydrateWhenTriggered.spec.ts: -------------------------------------------------------------------------------- 1 | import { createSSRApp, h, ref } from 'vue'; 2 | import { renderToString } from '@vue/server-renderer'; 3 | import { flushPromises } from '@vue/test-utils'; 4 | 5 | import { withSSRSetup, triggerEvent } from '../../test/utils'; 6 | 7 | import { useLazyHydration, useHydrateWhenTriggered } from '.'; 8 | 9 | beforeEach(() => { 10 | document.body.innerHTML = ''; 11 | }); 12 | 13 | it('should hydrate when trigger is true', async () => { 14 | const spyClick = vi.fn(); 15 | const trigger = ref(false); 16 | 17 | const { container } = await withSSRSetup(() => { 18 | const result = useLazyHydration(); 19 | 20 | useHydrateWhenTriggered(result, trigger); 21 | 22 | return () => h('button', { onClick: spyClick }, 'foo'); 23 | }); 24 | 25 | // hydration not complete yet 26 | triggerEvent('click', container.querySelector('button')); 27 | expect(spyClick).not.toHaveBeenCalled(); 28 | 29 | // trigger hydration and wait for it to complete 30 | trigger.value = true; 31 | await flushPromises(); 32 | 33 | // should be hydrated now 34 | triggerEvent('click', container.querySelector('button')); 35 | expect(spyClick).toHaveBeenCalledOnce(); 36 | }); 37 | 38 | it('should unWatch trigger when component has been unmounted', async () => { 39 | const show = ref(true); 40 | const trigger = ref(false); 41 | 42 | let spyHydrate; 43 | 44 | await withSSRSetup((isClient) => { 45 | const LazyComp = { 46 | setup() { 47 | const result = useLazyHydration(); 48 | 49 | if (isClient) { 50 | spyHydrate = vi.spyOn(result, 'hydrate'); 51 | } 52 | 53 | useHydrateWhenTriggered(result, trigger); 54 | 55 | return () => h('button', 'foo'); 56 | }, 57 | }; 58 | 59 | return () => h('div', [show.value ? h(LazyComp) : h('div', 'hi')]); 60 | }); 61 | 62 | // trigger onUnmounted hook 63 | show.value = false; 64 | await flushPromises(); 65 | 66 | // run watch effect 67 | trigger.value = true; 68 | await flushPromises(); 69 | 70 | // watch effect should have been stopped 71 | expect(spyHydrate).not.toHaveBeenCalledOnce(); 72 | }); 73 | 74 | it('should throw error when used outside of the setup or lifecycle hook method', async () => { 75 | const originalWindow = window; 76 | const handler = vi.fn(); 77 | const err = new Error( 78 | 'useHydrateWhenTriggered must be called from the setup or lifecycle hook methods.' 79 | ); 80 | 81 | let result: ReturnType; 82 | 83 | const LazyComp = { 84 | setup() { 85 | result = useLazyHydration(); 86 | 87 | return () => h('foo'); 88 | }, 89 | }; 90 | 91 | const App = { 92 | setup() { 93 | function onClick() { 94 | useHydrateWhenTriggered(result, () => true); 95 | } 96 | 97 | return () => [h('button', { onClick }, 'foo'), h(LazyComp)]; 98 | }, 99 | }; 100 | 101 | const app = createSSRApp(App); 102 | 103 | // make sure window is undefined at server-side 104 | vi.stubGlobal('window', undefined); 105 | 106 | // render at server-side 107 | const html = await renderToString(app); 108 | 109 | // restore window for client-side 110 | vi.stubGlobal('window', originalWindow); 111 | 112 | const container = document.createElement('div'); 113 | 114 | container.innerHTML = html; 115 | 116 | document.append(container); 117 | 118 | app.config.errorHandler = handler; 119 | 120 | // hydrate application 121 | app.mount(container); 122 | 123 | // trigger error 124 | triggerEvent('click', container.querySelector('button')); 125 | 126 | expect(handler).toHaveBeenCalled(); 127 | expect(handler.mock.calls[0][0]).toStrictEqual(err); 128 | }); 129 | -------------------------------------------------------------------------------- /src/composables/useHydrateWhenTriggered.ts: -------------------------------------------------------------------------------- 1 | import { watch, isRef, getCurrentInstance, type Ref } from 'vue'; 2 | import type useLazyHydration from './useLazyHydration'; 3 | 4 | /** 5 | * @public A Vue.js composable to manually trigger hydration. 6 | */ 7 | export default function useHydrateWhenTriggered( 8 | { 9 | willPerformHydration, 10 | hydrate, 11 | onCleanup, 12 | }: ReturnType, 13 | trigger: Ref | (() => boolean) 14 | ) { 15 | if (!willPerformHydration) { 16 | return; 17 | } 18 | 19 | if (!getCurrentInstance()) { 20 | throw new Error( 21 | 'useHydrateWhenTriggered must be called from the setup or lifecycle hook methods.' 22 | ); 23 | } 24 | 25 | const unWatch = watch( 26 | isRef(trigger) ? trigger : () => trigger, 27 | (isTriggered) => { 28 | if (isTriggered) { 29 | hydrate(); 30 | } 31 | }, 32 | { 33 | immediate: true, 34 | } 35 | ); 36 | 37 | onCleanup(unWatch); 38 | } 39 | -------------------------------------------------------------------------------- /src/composables/useHydrateWhenVisible.spec.ts: -------------------------------------------------------------------------------- 1 | import { h, onMounted, ref } from 'vue'; 2 | import { flushPromises } from '@vue/test-utils'; 3 | 4 | import { ensureMocksReset, intersectionObserver } from '../../test/dom-mocks'; 5 | import { withSSRSetup, triggerEvent, createApp } from '../../test/utils'; 6 | 7 | import { createHydrationObserver } from '../utils'; 8 | import { useLazyHydration, useHydrateWhenVisible } from '.'; 9 | 10 | beforeEach(() => { 11 | document.body.innerHTML = ''; 12 | }); 13 | 14 | afterEach(() => { 15 | ensureMocksReset(); 16 | }); 17 | 18 | it('should hydrate when single root element is visible', async () => { 19 | intersectionObserver.mock(); 20 | const spyClick = vi.fn(); 21 | 22 | const { container } = await withSSRSetup(() => { 23 | const result = useLazyHydration(); 24 | 25 | useHydrateWhenVisible(result); 26 | 27 | return () => h('button', { onClick: spyClick }, 'foo'); 28 | }); 29 | 30 | // hydration not complete yet 31 | triggerEvent('click', container.querySelector('button')); 32 | expect(spyClick).not.toHaveBeenCalled(); 33 | expect( 34 | ( 35 | container.querySelector('button') as Element & { 36 | hydrate?: () => Promise; 37 | } 38 | ).hydrate 39 | ).toBeDefined(); 40 | 41 | // make an element outside lazily hydrated component visible 42 | intersectionObserver.simulate({ 43 | target: container.querySelector('div') || undefined, 44 | isIntersecting: false, 45 | }); 46 | await flushPromises(); 47 | 48 | // hydration not complete yet 49 | triggerEvent('click', container.querySelector('button')); 50 | expect(spyClick).not.toHaveBeenCalled(); 51 | expect( 52 | ( 53 | container.querySelector('button') as Element & { 54 | hydrate?: () => Promise; 55 | } 56 | ).hydrate 57 | ).toBeDefined(); 58 | 59 | // trigger hydration and wait for it to complete 60 | intersectionObserver.simulate({ 61 | target: container.querySelector('button') || undefined, 62 | isIntersecting: true, 63 | }); 64 | await flushPromises(); 65 | 66 | // should be hydrated now 67 | triggerEvent('click', container.querySelector('button')); 68 | expect(spyClick).toHaveBeenCalledOnce(); 69 | expect( 70 | ( 71 | container.querySelector('button') as Element & { 72 | hydrate?: () => Promise; 73 | } 74 | ).hydrate 75 | ).toBeUndefined(); 76 | 77 | intersectionObserver.restore(); 78 | }); 79 | 80 | it('should hydrate when one of multiple root elements is visible', async () => { 81 | intersectionObserver.mock(); 82 | const spyClick = vi.fn(); 83 | 84 | const { container } = await withSSRSetup(() => { 85 | const result = useLazyHydration(); 86 | 87 | useHydrateWhenVisible(result); 88 | 89 | return () => [ 90 | h('button', { onClick: spyClick }, 'first element'), 91 | h('span', 'second element'), 92 | h('p', 'last element'), 93 | ]; 94 | }); 95 | 96 | // hydration not complete yet 97 | triggerEvent('click', container.querySelector('button')); 98 | expect(spyClick).not.toHaveBeenCalled(); 99 | 100 | // trigger hydration (intersect on second root element) and wait for it to complete 101 | intersectionObserver.simulate({ 102 | target: container.querySelector('span') || undefined, 103 | isIntersecting: true, 104 | }); 105 | await flushPromises(); 106 | 107 | // should be hydrated now 108 | triggerEvent('click', container.querySelector('button')); 109 | expect(spyClick).toHaveBeenCalledOnce(); 110 | 111 | intersectionObserver.restore(); 112 | }); 113 | 114 | it('should hydrate when IntersectionObserver API is unsupported', async () => { 115 | intersectionObserver.mockAsUnsupported(); 116 | 117 | const spyClick = vi.fn(); 118 | 119 | const { container } = await withSSRSetup(() => { 120 | const result = useLazyHydration(); 121 | 122 | useHydrateWhenVisible(result); 123 | 124 | return () => h('button', { onClick: spyClick }, 'foo'); 125 | }); 126 | 127 | expect(window.IntersectionObserver).toBeUndefined(); 128 | 129 | // should be hydrated now 130 | triggerEvent('click', container.querySelector('button')); 131 | expect(spyClick).toHaveBeenCalled(); 132 | 133 | intersectionObserver.restore(); 134 | }); 135 | 136 | it('should unobserve root elements when component has been unmounted', async () => { 137 | intersectionObserver.mock(); 138 | const show = ref(true); 139 | 140 | const { observer } = createHydrationObserver(); 141 | const spyUnobserve = vi.spyOn(observer!, 'unobserve'); 142 | 143 | await withSSRSetup(() => { 144 | const LazyComp = { 145 | setup() { 146 | const result = useLazyHydration(); 147 | 148 | useHydrateWhenVisible(result); 149 | 150 | return () => [ 151 | h('button', 'first element'), 152 | h('span', 'second element'), 153 | h('p', 'last element'), 154 | ]; 155 | }, 156 | }; 157 | 158 | return () => h('div', [show.value ? h(LazyComp) : h('div', 'hi')]); 159 | }); 160 | 161 | // trigger onUnmounted hook 162 | show.value = false; 163 | 164 | await flushPromises(); 165 | 166 | expect(spyUnobserve).toHaveBeenCalledTimes(3); 167 | 168 | intersectionObserver.restore(); 169 | }); 170 | 171 | it('should throw error when used outside of the setup method', async () => { 172 | const handler = vi.fn(); 173 | const err = new Error( 174 | 'useHydrateWhenVisible must be called from the setup method.' 175 | ); 176 | 177 | const container = document.createElement('div'); 178 | container.innerHTML = 'foo'; 179 | document.append(container); 180 | 181 | const app = createApp(() => { 182 | const result = useLazyHydration(); 183 | 184 | onMounted(() => { 185 | useHydrateWhenVisible(result); 186 | }); 187 | 188 | return () => 'foo'; 189 | }); 190 | 191 | app.config.errorHandler = handler; 192 | 193 | app.mount(container); 194 | 195 | expect(handler).toHaveBeenCalled(); 196 | expect(handler.mock.calls[0][0]).toStrictEqual(err); 197 | }); 198 | -------------------------------------------------------------------------------- /src/composables/useHydrateWhenVisible.ts: -------------------------------------------------------------------------------- 1 | import { getCurrentInstance, onMounted } from 'vue'; 2 | import { createHydrationObserver, getRootElements } from '../utils'; 3 | import type useLazyHydration from './useLazyHydration'; 4 | 5 | /** 6 | * @public A Vue.js composable to delay hydration until one of the component's root elements is visible. 7 | */ 8 | export default function useHydrateWhenVisible( 9 | { 10 | willPerformHydration, 11 | hydrate, 12 | onCleanup, 13 | }: ReturnType, 14 | observerOptions?: IntersectionObserverInit 15 | ) { 16 | if (!willPerformHydration) { 17 | return; 18 | } 19 | 20 | const instance = getCurrentInstance(); 21 | 22 | if (!instance || instance.isMounted) { 23 | throw new Error( 24 | 'useHydrateWhenVisible must be called from the setup method.' 25 | ); 26 | } 27 | 28 | const { supported, observer } = createHydrationObserver(observerOptions); 29 | 30 | // If Intersection Observer API is not supported, hydrate immediately. 31 | if (!supported) { 32 | hydrate(); 33 | 34 | return; 35 | } 36 | 37 | onMounted(() => { 38 | const els = getRootElements(instance); 39 | 40 | els.forEach((target) => { 41 | target.hydrate = hydrate; 42 | 43 | observer!.observe(target as Element); 44 | }); 45 | 46 | onCleanup(() => { 47 | els.forEach((target) => { 48 | delete target.hydrate; 49 | 50 | observer!.unobserve(target as Element); 51 | }); 52 | }); 53 | }); 54 | } 55 | -------------------------------------------------------------------------------- /src/composables/useLazyHydration.spec.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-void */ 2 | /* eslint-disable no-underscore-dangle */ 3 | import { 4 | defineAsyncComponent, 5 | defineComponent, 6 | getCurrentInstance, 7 | h, 8 | Suspense, 9 | onMounted, 10 | ref, 11 | type Component, 12 | type ComponentInternalInstance, 13 | } from 'vue'; 14 | import { flushPromises } from '@vue/test-utils'; 15 | 16 | import { withSSRSetup, triggerEvent, createApp } from '../../test/utils'; 17 | 18 | import { useLazyHydration } from '.'; 19 | 20 | interface Result { 21 | client: ReturnType | null; 22 | server: ReturnType | null; 23 | } 24 | 25 | it('should delay hydration', async () => { 26 | const result: Result = { 27 | client: null, 28 | server: null, 29 | }; 30 | 31 | const spyClick = vi.fn(); 32 | 33 | const { container } = await withSSRSetup((isClient) => { 34 | result[isClient ? 'client' : 'server'] = useLazyHydration(); 35 | 36 | return () => h('button', { onClick: spyClick }, 'foo'); 37 | }); 38 | 39 | expect(result.client?.willPerformHydration).toBe(true); 40 | expect(result.server?.willPerformHydration).toBe(false); 41 | 42 | // hydration not complete yet 43 | triggerEvent('click', container.querySelector('button')); 44 | expect(spyClick).not.toHaveBeenCalled(); 45 | 46 | // trigger hydration and wait for it to complete 47 | void result.client!.hydrate!(); 48 | await flushPromises(); 49 | 50 | // should be hydrated now 51 | triggerEvent('click', container.querySelector('button')); 52 | expect(spyClick).toHaveBeenCalled(); 53 | }); 54 | 55 | it('should run onCleanup hook when component has been unmounted', async () => { 56 | const result: Result = { 57 | client: null, 58 | server: null, 59 | }; 60 | 61 | const spyCleanup = vi.fn(); 62 | 63 | const show = ref(true); 64 | 65 | await withSSRSetup((isClient) => { 66 | const LazyComp = { 67 | setup() { 68 | result[isClient ? 'client' : 'server'] = useLazyHydration(); 69 | 70 | if (isClient) { 71 | result.client!.onCleanup!(spyCleanup); 72 | } 73 | 74 | return () => h('button', 'foo'); 75 | }, 76 | }; 77 | 78 | return () => h('div', [show.value ? h(LazyComp) : h('div', 'hi')]); 79 | }); 80 | 81 | // trigger onUnmounted hook 82 | show.value = false; 83 | 84 | await flushPromises(); 85 | 86 | expect(spyCleanup).toHaveBeenCalledOnce(); 87 | }); 88 | 89 | it('should not update component if hydration is delayed', async () => { 90 | const result: Result = { 91 | client: null, 92 | server: null, 93 | }; 94 | 95 | const spyClick = vi.fn(); 96 | 97 | const color = ref('red'); 98 | 99 | const { container } = await withSSRSetup((isClient) => { 100 | const LazyComp = { 101 | setup() { 102 | result[isClient ? 'client' : 'server'] = useLazyHydration(); 103 | 104 | return () => h('button', { onClick: spyClick }, 'foo'); 105 | }, 106 | }; 107 | 108 | return () => h('div', h(LazyComp, { style: { color: color.value } })); 109 | }); 110 | 111 | expect(result.client?.willPerformHydration).toBe(true); 112 | expect(result.server?.willPerformHydration).toBe(false); 113 | 114 | // should have not been hydrated 115 | triggerEvent('click', container.querySelector('button')); 116 | expect(spyClick).not.toHaveBeenCalled(); 117 | 118 | // trigger an update and wait for it to complete 119 | color.value = 'yellow'; 120 | await flushPromises(); 121 | 122 | // should have not been updated 123 | triggerEvent('click', container.querySelector('button')); 124 | expect(spyClick).not.toHaveBeenCalled(); 125 | expect(container.querySelector('button')?.style.color).toBe('red'); 126 | }); 127 | 128 | it('should update props even if hydration is delayed', async () => { 129 | const result: Result = { 130 | client: null, 131 | server: null, 132 | }; 133 | 134 | const spyClick = vi.fn(); 135 | 136 | const bar = ref(false); 137 | 138 | let lazyCompInstance: ComponentInternalInstance | null; 139 | 140 | const { container } = await withSSRSetup((isClient) => { 141 | const LazyComp: Component = { 142 | props: ['foo'], 143 | setup(props) { 144 | result[isClient ? 'client' : 'server'] = useLazyHydration(); 145 | 146 | lazyCompInstance = getCurrentInstance(); 147 | 148 | return () => h('button', { onClick: spyClick }, props.foo as boolean); 149 | }, 150 | }; 151 | 152 | return () => h('div', h(LazyComp, { foo: bar.value })); 153 | }); 154 | 155 | expect(result.client?.willPerformHydration).toBe(true); 156 | expect(result.server?.willPerformHydration).toBe(false); 157 | 158 | // should have not been hydrated 159 | triggerEvent('click', container.querySelector('button')); 160 | expect(spyClick).not.toHaveBeenCalled(); 161 | expect(container.querySelector('button')?.innerText).toBe('false'); 162 | expect(lazyCompInstance!.props.foo).toBeFalsy(); 163 | 164 | // update props and wait for it to complete 165 | bar.value = true; 166 | await flushPromises(); 167 | 168 | // should have only updated props 169 | triggerEvent('click', container.querySelector('button')); 170 | expect(spyClick).not.toHaveBeenCalled(); 171 | expect(container.querySelector('button')?.innerText).toBe('false'); 172 | expect(lazyCompInstance!.props.foo).toBeTruthy(); 173 | }); 174 | 175 | it('should update props even if hydration is delayed (with Suspense)', async () => { 176 | // is an experimental feature and its API will likely change. 177 | vi.spyOn(console, 'info').mockImplementation(() => {}); 178 | 179 | const result: Result = { 180 | client: null, 181 | server: null, 182 | }; 183 | 184 | const spyClick = vi.fn(); 185 | 186 | const bar = ref(false); 187 | 188 | let lazyCompInstance: ComponentInternalInstance | null; 189 | 190 | const { container } = await withSSRSetup((isClient) => { 191 | const LazyComp: Component = { 192 | props: ['foo'], 193 | setup(props) { 194 | result[isClient ? 'client' : 'server'] = useLazyHydration(); 195 | 196 | lazyCompInstance = getCurrentInstance(); 197 | 198 | return () => h('button', { onClick: spyClick }, props.foo as boolean); 199 | }, 200 | }; 201 | 202 | return () => h(Suspense, h(LazyComp, { foo: bar.value })); 203 | }); 204 | 205 | expect(result.client?.willPerformHydration).toBe(true); 206 | expect(result.server?.willPerformHydration).toBe(false); 207 | 208 | // should have not been hydrated 209 | triggerEvent('click', container.querySelector('button')); 210 | expect(spyClick).not.toHaveBeenCalled(); 211 | expect(container.querySelector('button')?.innerText).toBe('false'); 212 | expect(lazyCompInstance!.props.foo).toBeFalsy(); 213 | 214 | // update props and wait for it to complete 215 | bar.value = true; 216 | await flushPromises(); 217 | 218 | // should have only updated props 219 | triggerEvent('click', container.querySelector('button')); 220 | expect(spyClick).not.toHaveBeenCalled(); 221 | expect(container.querySelector('button')?.innerText).toBe('false'); 222 | expect(lazyCompInstance!.props.foo).toBeTruthy(); 223 | 224 | vi.restoreAllMocks(); 225 | }); 226 | 227 | it('should not break if the parent is a renderless component and has been updated', async () => { 228 | const result: Result = { 229 | client: null, 230 | server: null, 231 | }; 232 | 233 | const spyClick = vi.fn(); 234 | 235 | const foo = ref(false); 236 | 237 | let lazyCompInstance: ComponentInternalInstance | null; 238 | 239 | const { container } = await withSSRSetup((isClient) => { 240 | const LazyComp = defineComponent({ 241 | setup() { 242 | result[isClient ? 'client' : 'server'] = useLazyHydration(); 243 | 244 | lazyCompInstance = getCurrentInstance(); 245 | 246 | return () => h('button', { onClick: spyClick }); 247 | }, 248 | }); 249 | 250 | const AsyncComp = defineAsyncComponent( 251 | () => 252 | new Promise((resolve) => { 253 | resolve(LazyComp as never); 254 | }) 255 | ); 256 | 257 | return () => 258 | h( 259 | 'div', 260 | h(AsyncComp, { 261 | foo: foo.value, 262 | }) 263 | ); 264 | }); 265 | 266 | expect(result.client?.willPerformHydration).toBeUndefined(); 267 | expect(result.server?.willPerformHydration).toBe(false); 268 | 269 | // wait for the lazy hydrated component to be resolved by async wrapper 270 | await flushPromises(); 271 | 272 | expect(result.client?.willPerformHydration).toBe(true); 273 | expect(result.server?.willPerformHydration).toBe(false); 274 | 275 | // should have not been hydrated yet 276 | triggerEvent('click', container.querySelector('button')); 277 | expect(spyClick).not.toHaveBeenCalled(); 278 | 279 | // update parent component 280 | foo.value = true; 281 | await flushPromises(); 282 | 283 | // parent component should still have a subtree element 284 | expect(lazyCompInstance!.parent?.subTree.el).not.toBeNull(); 285 | 286 | // should have not been hydrated yet 287 | triggerEvent('click', container.querySelector('button')); 288 | expect(spyClick).not.toHaveBeenCalled(); 289 | 290 | // update parent component 291 | foo.value = false; 292 | await flushPromises(); 293 | 294 | // DOM patching should not be broken 295 | expect( 296 | 'Unhandled error during execution of scheduler flush' 297 | ).not.toHaveBeenWarned(); 298 | 299 | // trigger hydration and wait for it to complete 300 | void result.client!.hydrate!(); 301 | await flushPromises(); 302 | 303 | // should be hydrated now 304 | triggerEvent('click', container.querySelector('button')); 305 | expect(spyClick).toHaveBeenCalled(); 306 | expect( 307 | 'Unhandled error during execution of scheduler flush' 308 | ).not.toHaveBeenWarned(); 309 | }); 310 | 311 | it('should run onHydrated hook when component has been hydrated', async () => { 312 | const result: Result = { 313 | client: null, 314 | server: null, 315 | }; 316 | 317 | const spyClick = vi.fn(); 318 | const spyOnHydratedHook = vi.fn(); 319 | 320 | const { container } = await withSSRSetup((isClient) => { 321 | const LazyComp = { 322 | setup() { 323 | result[isClient ? 'client' : 'server'] = useLazyHydration(); 324 | 325 | if (isClient) { 326 | result.client?.onHydrated(spyOnHydratedHook); 327 | } 328 | 329 | return () => h('button', { onClick: spyClick }, 'foo'); 330 | }, 331 | }; 332 | 333 | return () => h(LazyComp); 334 | }); 335 | 336 | expect(result.client?.willPerformHydration).toBe(true); 337 | expect(result.server?.willPerformHydration).toBe(false); 338 | 339 | // hydration not complete yet 340 | triggerEvent('click', container.querySelector('button')); 341 | expect(spyClick).not.toHaveBeenCalled(); 342 | expect(spyOnHydratedHook).not.toHaveBeenCalled(); 343 | 344 | // trigger hydration and wait for it to complete 345 | void result.client!.hydrate!(); 346 | expect(spyOnHydratedHook).not.toHaveBeenCalled(); 347 | await flushPromises(); 348 | 349 | // should be hydrated now 350 | triggerEvent('click', container.querySelector('button')); 351 | expect(spyClick).toHaveBeenCalled(); 352 | expect(spyOnHydratedHook).toHaveBeenCalledOnce(); 353 | }); 354 | 355 | it('should run onHydrated hook when component has been hydrated and its children async components resolved', async () => { 356 | const result: Result & { client: { resolveAsyncComp?: () => void } | null } = 357 | { 358 | client: null, 359 | server: null, 360 | }; 361 | 362 | const spyClick = vi.fn(); 363 | const spyAsyncCompClick = vi.fn(); 364 | const spyOnHydratedHook = vi.fn(); 365 | 366 | const { container } = await withSSRSetup((isClient) => { 367 | const LazyComp = { 368 | setup() { 369 | result[isClient ? 'client' : 'server'] = useLazyHydration(); 370 | 371 | let resolveAsyncComp!: () => void; 372 | 373 | const promise: Promise = new Promise((resolve) => { 374 | resolveAsyncComp = () => 375 | resolve({ 376 | setup() { 377 | return () => 378 | h( 379 | 'button', 380 | { class: 'async', onClick: spyAsyncCompClick }, 381 | 'foo' 382 | ); 383 | }, 384 | }); 385 | }); 386 | 387 | if (!isClient) { 388 | resolveAsyncComp(); 389 | } else { 390 | result.client!.resolveAsyncComp = resolveAsyncComp; 391 | result.client!.onHydrated(spyOnHydratedHook); 392 | } 393 | 394 | return () => [ 395 | h('button', { class: 'lazy', onClick: spyClick }, 'foo'), 396 | h(defineAsyncComponent(() => promise)), 397 | ]; 398 | }, 399 | }; 400 | 401 | return () => h(LazyComp); 402 | }); 403 | 404 | expect(result.client?.willPerformHydration).toBe(true); 405 | expect(result.server?.willPerformHydration).toBe(false); 406 | 407 | // hydration not complete yet 408 | triggerEvent('click', container.querySelector('button.lazy')); 409 | triggerEvent('click', container.querySelector('button.async')); 410 | 411 | expect(spyClick).not.toHaveBeenCalled(); 412 | expect(spyAsyncCompClick).not.toHaveBeenCalled(); 413 | expect(spyOnHydratedHook).not.toHaveBeenCalled(); 414 | 415 | // trigger hydration and wait for it to complete 416 | result.client!.hydrate!(); 417 | await flushPromises(); 418 | 419 | // should be hydrated now 420 | triggerEvent('click', container.querySelector('button.lazy')); 421 | triggerEvent('click', container.querySelector('button.async')); 422 | expect(spyClick).toHaveBeenCalled(); 423 | 424 | // but did not run onHydrated hook yet 425 | expect(spyOnHydratedHook).not.toHaveBeenCalled(); 426 | 427 | // wait for resolved async component 428 | result.client!.resolveAsyncComp!(); 429 | await flushPromises(); 430 | 431 | triggerEvent('click', container.querySelector('button.async')); 432 | expect(spyAsyncCompClick).toHaveBeenCalled(); 433 | expect(spyOnHydratedHook).toHaveBeenCalledOnce(); 434 | }); 435 | 436 | it('should throw error when used outside of the setup method', () => { 437 | const handler = vi.fn(); 438 | const err = new Error( 439 | 'useLazyHydration must be called from the setup method.' 440 | ); 441 | 442 | const container = document.createElement('div'); 443 | container.innerHTML = 'foo'; 444 | document.append(container); 445 | 446 | const app = createApp(() => { 447 | onMounted(() => { 448 | useLazyHydration(); 449 | }); 450 | 451 | return () => 'foo'; 452 | }); 453 | 454 | app.config.errorHandler = handler; 455 | 456 | app.mount(container); 457 | 458 | expect(handler).toHaveBeenCalled(); 459 | expect(handler.mock.calls[0][0]).toStrictEqual(err); 460 | }); 461 | -------------------------------------------------------------------------------- /src/composables/useLazyHydration.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-underscore-dangle */ 2 | 3 | import { 4 | type ComponentInternalInstance, 5 | type ConcreteComponent, 6 | getCurrentInstance, 7 | nextTick, 8 | onBeforeMount, 9 | onUnmounted, 10 | type VNodeTypes, 11 | type VNodeChild, 12 | } from 'vue'; 13 | 14 | import { 15 | trackDepsOnRender, 16 | createHydrationPromise, 17 | createHydrationCleanup, 18 | waitForAsyncComponents, 19 | ensureParentHasSubTreeEl, 20 | } from '../utils'; 21 | 22 | export type ExtendedVnodeTypes = VNodeTypes & { __isLazilyHydrated?: boolean }; 23 | export type ExtendedConcreteComponent = ConcreteComponent & { 24 | __asyncLoader?: () => Promise; 25 | }; 26 | export type ExtendedComponentInternalInstance = ComponentInternalInstance & { 27 | asyncDep: Promise | null; 28 | }; 29 | 30 | /** 31 | * @public A Vue.js composable to delay hydration until the hydrate function is called. 32 | */ 33 | export default function useLazyHydration() { 34 | const instance = getCurrentInstance(); 35 | 36 | if (!instance || instance.isMounted) { 37 | throw new Error('useLazyHydration must be called from the setup method.'); 38 | } 39 | 40 | const willPerformHydration = instance.vnode.el !== null; 41 | 42 | /** 43 | * Set hint on the component. 44 | * 45 | * One of the purposes is to help the server side renderer 46 | * to avoid injecting/preloading the lazily hydrated components chunks into the rendered html. 47 | * This way they can be loaded on-demand (when hydrating) at client side. 48 | * 49 | * Components should be wrapped with defineAsyncComponent() 50 | * to let the bundler doing code-splitting. 51 | */ 52 | (instance.vnode.type as ExtendedVnodeTypes).__isLazilyHydrated = true; 53 | 54 | if (!willPerformHydration) { 55 | /** 56 | * The application is actually running at server-side 57 | * or subsequent navigation occurred at client-side after the first load. 58 | */ 59 | return { willPerformHydration, onHydrated: () => {} }; 60 | } 61 | 62 | const { cleanup, onCleanup } = createHydrationCleanup(); 63 | 64 | const { 65 | promise, 66 | resolvePromise: hydrate, 67 | onResolvedPromise: onBeforeHydrate, 68 | } = createHydrationPromise(cleanup); 69 | 70 | const onHydrated = (cb: () => void) => 71 | onBeforeHydrate(() => nextTick(() => waitForAsyncComponents(instance, cb))); 72 | 73 | /** 74 | * Move the render call into an async callback. 75 | * This delays hydration until the promise is resolved. 76 | * @see https://github.com/vuejs/core/blob/v3.2.36/packages/runtime-core/src/renderer.ts#L1361&L1369 77 | */ 78 | (instance.type as ExtendedConcreteComponent).__asyncLoader = () => promise; 79 | 80 | /** 81 | * In some cases the parent subtree element might be set to null 82 | * which breaks DOM patching. 83 | * 84 | * It occurs when the parent is a renderless component (an async wrapper for example) 85 | * and has been updated before the actual component has been hydrated. 86 | */ 87 | ensureParentHasSubTreeEl( 88 | instance.parent as ComponentInternalInstance & { u: null | (() => void)[] } 89 | ); 90 | 91 | /** 92 | * In case a parent triggers an update, 93 | * trick the renderer to just update props and slots. 94 | * @see https://github.com/vuejs/core/blob/v3.2.36/packages/runtime-core/src/renderer.ts#L1269&L1275 95 | */ 96 | onBeforeMount(() => { 97 | (instance as ExtendedComponentInternalInstance).asyncDep = 98 | new Promise((r) => { 99 | r(true); 100 | }); 101 | }); 102 | 103 | onBeforeHydrate(() => { 104 | /** 105 | * The render call has been moved into an async callback 106 | * which means it won't track dependencies. 107 | * 108 | * Re-run the reactive effect in sync with the render call. 109 | */ 110 | trackDepsOnRender( 111 | instance as ComponentInternalInstance & { 112 | render: () => VNodeChild; 113 | } 114 | ); 115 | 116 | // allow subsequent full updates 117 | (instance as ExtendedComponentInternalInstance).asyncDep = null; 118 | }); 119 | 120 | onUnmounted(cleanup); 121 | 122 | return { willPerformHydration, hydrate, onHydrated, onCleanup }; 123 | } 124 | -------------------------------------------------------------------------------- /src/env.d.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/naming-convention, no-underscore-dangle 2 | declare const __DEV__: string; 3 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { default as LazyHydrationWrapper } from './components/LazyHydrationWrapper'; 2 | export * from './composables'; 3 | export * from './wrappers'; 4 | -------------------------------------------------------------------------------- /src/utils/create-hydration-cleanup.ts: -------------------------------------------------------------------------------- 1 | export default function createHydrationCleanup() { 2 | let cleanups: (() => void)[] = []; 3 | 4 | const onCleanup = (cb: () => void) => { 5 | cleanups.push(cb); 6 | }; 7 | 8 | const cleanup = () => { 9 | // run each cleaning function then remove it from array 10 | cleanups = cleanups.filter((fn: () => void) => { 11 | fn(); 12 | 13 | return false; 14 | }); 15 | }; 16 | 17 | return { cleanup, onCleanup }; 18 | } 19 | -------------------------------------------------------------------------------- /src/utils/create-hydration-observer.ts: -------------------------------------------------------------------------------- 1 | const observers = new Map(); 2 | 3 | export default function createHydrationObserver( 4 | options?: IntersectionObserverInit 5 | ): { 6 | supported: boolean; 7 | observer?: IntersectionObserver; 8 | } { 9 | const supported = typeof IntersectionObserver !== 'undefined'; 10 | 11 | if (!supported) { 12 | return { supported }; 13 | } 14 | 15 | const optionKey = JSON.stringify(options); 16 | 17 | if (observers.has(optionKey)) { 18 | return { supported, observer: observers.get(optionKey) }; 19 | } 20 | 21 | const observer = new IntersectionObserver((entries) => { 22 | entries.forEach( 23 | ( 24 | entry: IntersectionObserverEntry & { 25 | target: { hydrate?: () => Promise }; 26 | } 27 | ) => { 28 | // Use `intersectionRatio` because of Edge 15's 29 | // lack of support for `isIntersecting`. 30 | // See: https://github.com/w3c/IntersectionObserver/issues/211 31 | const isIntersecting = 32 | entry.isIntersecting || entry.intersectionRatio > 0; 33 | 34 | if (!isIntersecting || !entry.target.hydrate) { 35 | return; 36 | } 37 | 38 | entry.target.hydrate(); 39 | } 40 | ); 41 | }, options); 42 | 43 | observers.set(optionKey, observer); 44 | 45 | return { supported, observer }; 46 | } 47 | -------------------------------------------------------------------------------- /src/utils/create-hydration-promise.ts: -------------------------------------------------------------------------------- 1 | export default function createHydrationPromise(cleanup: () => void) { 2 | let resolvePromise: () => void | Promise = () => {}; 3 | 4 | const promise = new Promise((resolve) => { 5 | resolvePromise = () => { 6 | cleanup(); 7 | 8 | resolve(); 9 | }; 10 | }); 11 | 12 | const onResolvedPromise = (cb: () => void | Promise) => { 13 | // eslint-disable-next-line no-void 14 | void promise.then(cb); 15 | }; 16 | 17 | return { 18 | promise, 19 | resolvePromise, 20 | onResolvedPromise, 21 | }; 22 | } 23 | -------------------------------------------------------------------------------- /src/utils/create-hydration-wrapper.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | h, 3 | createApp as createClientApp, 4 | ref, 5 | nextTick, 6 | type RendererElement, 7 | type RendererNode, 8 | type VNode, 9 | type ConcreteComponent, 10 | } from 'vue'; 11 | 12 | import { renderToString } from '@vue/server-renderer'; 13 | import { flushPromises } from '@vue/test-utils'; 14 | 15 | import { expect } from 'vitest'; 16 | import { createApp, triggerEvent } from '../../test/utils'; 17 | 18 | import { createHydrationWrapper } from '.'; 19 | 20 | beforeEach(() => { 21 | document.body.innerHTML = ''; 22 | }); 23 | 24 | it('should handle error at server-side', async () => { 25 | const originalWindow = window; 26 | const handler = vi.fn(); 27 | const err = new Error('foo'); 28 | 29 | const WrappedComp = createHydrationWrapper( 30 | () => Promise.reject(err), 31 | () => {} 32 | ); 33 | 34 | // make sure window is undefined at server-side 35 | vi.stubGlobal('window', undefined); 36 | 37 | const app = createApp(() => () => h(WrappedComp)); 38 | 39 | app.config.errorHandler = handler; 40 | 41 | await renderToString(app); 42 | 43 | expect(handler).toHaveBeenCalled(); 44 | expect(handler.mock.calls[0][0]).toBe(err); 45 | 46 | // restore window 47 | vi.stubGlobal('window', originalWindow); 48 | }); 49 | 50 | it('should handle error at client-side when hydrating', async () => { 51 | const handler = vi.fn(); 52 | const spyClick = vi.fn(); 53 | const err = new Error('foo'); 54 | 55 | let resolve!: ( 56 | arg0: () => VNode 57 | ) => void; 58 | let reject!: (arg0: Error) => void; 59 | let hydrate!: () => void | Promise; 60 | 61 | // pre-rendered html 62 | const container = document.createElement('div'); 63 | container.innerHTML = ''; 64 | document.append(container); 65 | 66 | const WrappedComp = createHydrationWrapper( 67 | () => 68 | new Promise((_resolve, _reject) => { 69 | resolve = _resolve; 70 | reject = _reject; 71 | }), 72 | (result) => { 73 | hydrate = result.hydrate!; 74 | } 75 | ); 76 | 77 | const app = createApp(() => () => h(WrappedComp)); 78 | 79 | app.config.errorHandler = handler; 80 | 81 | // hydrate application 82 | app.mount(container); 83 | 84 | await flushPromises(); 85 | 86 | // simulate error when loading component 87 | hydrate(); 88 | reject(err); 89 | 90 | await flushPromises(); 91 | 92 | // should not be hydrated yet 93 | triggerEvent('click', container.querySelector('button')); 94 | expect(spyClick).not.toHaveBeenCalled(); 95 | expect(handler).toHaveBeenCalledOnce(); 96 | expect(handler.mock.calls[0][0]).toBe(err); 97 | 98 | // successfully load component this time 99 | hydrate(); 100 | resolve(() => h('button', { onClick: spyClick }, 'foo')); 101 | 102 | await flushPromises(); 103 | 104 | // should be hydrated now 105 | triggerEvent('click', container.querySelector('button')); 106 | expect(spyClick).toHaveBeenCalledOnce(); 107 | }); 108 | 109 | it('should handle error at client-side without hydration', async () => { 110 | const handler = vi.fn(); 111 | const err = new Error('foo'); 112 | 113 | let resolve!: (arg0: ConcreteComponent) => void; 114 | let reject!: (arg0: Error) => void; 115 | 116 | const container = document.createElement('div'); 117 | document.append(container); 118 | 119 | const WrappedComp = createHydrationWrapper( 120 | () => 121 | new Promise((_resolve, _reject) => { 122 | resolve = _resolve; 123 | reject = _reject; 124 | }), 125 | () => {} 126 | ); 127 | 128 | const toggle = ref(true); 129 | const app = createClientApp({ 130 | setup() { 131 | return () => (toggle.value ? h(WrappedComp) : null); 132 | }, 133 | }); 134 | 135 | app.config.errorHandler = handler; 136 | 137 | app.mount(container); 138 | 139 | await flushPromises(); 140 | expect(container.innerHTML).toBe(''); 141 | 142 | // simulate error when loading component 143 | reject(err); 144 | await flushPromises(); 145 | 146 | // should not be loaded yet 147 | expect(handler).toHaveBeenCalledOnce(); 148 | expect(handler.mock.calls[0][0]).toBe(err); 149 | expect(container.innerHTML).toBe(''); 150 | 151 | toggle.value = false; 152 | await nextTick(); 153 | expect(container.innerHTML).toBe(''); 154 | 155 | // errored out on previous load, toggle and mock success this time 156 | toggle.value = true; 157 | await nextTick(); 158 | expect(container.innerHTML).toBe(''); 159 | 160 | // should render this time 161 | resolve(() => 'resolved'); 162 | await flushPromises(); 163 | expect(container.innerHTML).toBe('resolved'); 164 | }); 165 | 166 | it('should handle error when load result is invalid', async () => { 167 | const originalWindow = window; 168 | const handler = vi.fn(); 169 | const err = new Error( 170 | 'Invalid async lazily hydrated wrapped component load result: invalid' 171 | ); 172 | 173 | const WrappedComp = createHydrationWrapper( 174 | () => Promise.resolve('invalid'), 175 | () => {} 176 | ); 177 | 178 | // make sure window is undefined at server-side 179 | vi.stubGlobal('window', undefined); 180 | 181 | const app = createApp(() => () => h(WrappedComp)); 182 | 183 | app.config.errorHandler = handler; 184 | 185 | await renderToString(app); 186 | 187 | expect(handler).toHaveBeenCalled(); 188 | expect(handler.mock.calls[0][0]).toStrictEqual(err); 189 | 190 | // restore window 191 | vi.stubGlobal('window', originalWindow); 192 | }); 193 | 194 | it('should warn when component loader resolved to undefined', async () => { 195 | const originalWindow = window; 196 | 197 | const WrappedComp = createHydrationWrapper( 198 | () => Promise.resolve(), 199 | () => {} 200 | ); 201 | 202 | // make sure window is undefined at server-side 203 | vi.stubGlobal('window', undefined); 204 | 205 | const app = createApp(() => () => h(WrappedComp)); 206 | 207 | await renderToString(app); 208 | 209 | expect( 210 | 'Invalid vnode type when creating vnode: undefined.' 211 | ).toHaveBeenWarned(); 212 | 213 | expect( 214 | 'Async lazily hydrated wrapped component loader resolved to undefined.' 215 | ).toHaveBeenWarned(); 216 | 217 | // restore window 218 | vi.stubGlobal('window', originalWindow); 219 | }); 220 | -------------------------------------------------------------------------------- /src/utils/create-hydration-wrapper.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | /* eslint-disable no-underscore-dangle */ 3 | import { 4 | defineComponent, 5 | markRaw, 6 | getCurrentInstance, 7 | ref, 8 | createVNode, 9 | handleError, 10 | type Component, 11 | type ComponentInternalInstance, 12 | type ConcreteComponent, 13 | type AsyncComponentLoader, 14 | } from 'vue'; 15 | import { isFunction, isObject } from './helpers'; 16 | import { useLazyHydration } from '../composables'; 17 | 18 | function createInnerComp( 19 | comp: ConcreteComponent, 20 | { vnode: { ref: refOwner, props, children } }: ComponentInternalInstance 21 | ) { 22 | const vnode = createVNode(comp, props, children); 23 | 24 | // ensure inner component inherits the lazy hydrate wrapper's ref owner 25 | vnode.ref = refOwner; 26 | 27 | return vnode; 28 | } 29 | 30 | export default function createHydrationWrapper( 31 | source: Component | AsyncComponentLoader, 32 | onSetup: (result: ReturnType) => void 33 | ): Component { 34 | let pendingRequest: Promise | null = null; 35 | let resolvedComp: ConcreteComponent | undefined; 36 | 37 | const loader = isFunction(source) 38 | ? (source as AsyncComponentLoader) 39 | : () => Promise.resolve(source); 40 | 41 | const load = () => { 42 | let thisRequest: Promise; 43 | 44 | if (pendingRequest !== null) { 45 | return pendingRequest; 46 | } 47 | 48 | // eslint-disable-next-line no-return-assign, no-multi-assign 49 | return (thisRequest = pendingRequest = 50 | loader() 51 | .catch((err) => { 52 | throw err instanceof Error ? err : new Error(String(err)); 53 | }) 54 | .then((comp: any) => { 55 | if (thisRequest !== pendingRequest && pendingRequest !== null) { 56 | return pendingRequest; 57 | } 58 | 59 | if (__DEV__ && !comp) { 60 | console.warn( 61 | `Async lazily hydrated wrapped component loader resolved to undefined.` 62 | ); 63 | } 64 | 65 | // interop module default 66 | if ( 67 | comp && 68 | (comp.__esModule || comp[Symbol.toStringTag] === 'Module') 69 | ) { 70 | // eslint-disable-next-line no-param-reassign 71 | comp = comp.default; 72 | } 73 | 74 | if (__DEV__ && comp && !isObject(comp) && !isFunction(comp)) { 75 | throw new Error( 76 | `Invalid async lazily hydrated wrapped component load result: ${ 77 | comp as string 78 | }` 79 | ); 80 | } 81 | 82 | resolvedComp = comp; 83 | 84 | return comp; 85 | })); 86 | }; 87 | 88 | return markRaw( 89 | defineComponent({ 90 | name: 'LazyHydrationWrapper', 91 | inheritAttrs: false, 92 | suspensible: false, 93 | emits: ['hydrated'], 94 | 95 | get __asyncResolved() { 96 | return resolvedComp; 97 | }, 98 | 99 | setup(_, { emit }) { 100 | const instance = getCurrentInstance(); 101 | 102 | const onError = (err: unknown) => { 103 | pendingRequest = null; 104 | 105 | handleError(err, instance, 13); 106 | }; 107 | 108 | const loaded = ref(false); 109 | const result = useLazyHydration(); 110 | 111 | if (typeof window === 'undefined') { 112 | // on Server-side 113 | return load() 114 | .then((comp) => () => createInnerComp(comp, instance!)) 115 | .catch((err) => { 116 | onError(err); 117 | 118 | return () => null; 119 | }); 120 | } 121 | 122 | if (!result.willPerformHydration) { 123 | // already resolved 124 | if (resolvedComp) { 125 | return () => createInnerComp(resolvedComp!, instance!); 126 | } 127 | 128 | load() 129 | .then(() => { 130 | loaded.value = true; 131 | }) 132 | .catch((err) => { 133 | onError(err); 134 | }); 135 | 136 | return () => { 137 | if (loaded.value && resolvedComp) { 138 | return createInnerComp(resolvedComp, instance!); 139 | } 140 | 141 | return null; 142 | }; 143 | } 144 | 145 | const { hydrate } = result; 146 | 147 | // load component before hydrate 148 | result.hydrate = () => 149 | load() 150 | .then(() => { 151 | loaded.value = true; 152 | 153 | // eslint-disable-next-line no-void 154 | void hydrate(); 155 | }) 156 | .catch((err) => { 157 | onError(err); 158 | }); 159 | 160 | result.onHydrated(() => emit('hydrated')); 161 | 162 | onSetup(result); 163 | 164 | return () => createInnerComp(resolvedComp!, instance!); 165 | }, 166 | }) 167 | ); 168 | } 169 | -------------------------------------------------------------------------------- /src/utils/ensure-parent-has-subtree-el.ts: -------------------------------------------------------------------------------- 1 | import type { ComponentInternalInstance } from 'vue'; 2 | 3 | export default function ensureParentHasSubTreeEl( 4 | parent: ComponentInternalInstance & { u: null | (() => void)[] } 5 | ) { 6 | if (!parent || !parent.subTree) { 7 | return; 8 | } 9 | 10 | // Backup the parent subtree element and onUpdated hook. 11 | const parentSubTreeEl = parent.subTree.el; 12 | const parentOnUpdatedHook = parent.u; 13 | 14 | if (parent.u === null) { 15 | parent.u = []; 16 | } 17 | 18 | // onUpdated hook 19 | parent.u.push(() => { 20 | if (parent.subTree.el === null) { 21 | // Restore the parent subtree element. 22 | parent.subTree.el = parentSubTreeEl; 23 | } 24 | 25 | // Restore the parent onUpdated hook. 26 | parent.u = parentOnUpdatedHook; 27 | }); 28 | } 29 | -------------------------------------------------------------------------------- /src/utils/get-root-elements.ts: -------------------------------------------------------------------------------- 1 | import type { ComponentInternalInstance, RendererNode } from 'vue'; 2 | 3 | const DOMNodeTypes = { 4 | ELEMENT: 1, 5 | TEXT: 3, 6 | COMMENT: 8, 7 | }; 8 | 9 | const isElement = (node: RendererNode | null) => 10 | node && node.nodeType === DOMNodeTypes.ELEMENT; 11 | const isComment = (node: RendererNode | null) => 12 | node && node.nodeType === DOMNodeTypes.COMMENT; 13 | const isFragmentStart = (node: RendererNode | null) => 14 | isComment(node) && node?.data === '['; 15 | const isFragmentEnd = (node: RendererNode | null) => 16 | isComment(node) && node?.data === ']'; 17 | 18 | export default function getRootElements({ 19 | vnode, 20 | subTree, 21 | }: ComponentInternalInstance) { 22 | if (!vnode || vnode.el === null) { 23 | return []; 24 | } 25 | 26 | // single Root Element 27 | if (isElement(vnode.el)) { 28 | return [vnode.el]; 29 | } 30 | 31 | const els: Node[] = []; 32 | 33 | // multiple Root Elements 34 | if (subTree && isFragmentStart(subTree.el) && isFragmentEnd(subTree.anchor)) { 35 | let node = (vnode.el as Node).nextSibling; 36 | 37 | while (node) { 38 | if (node && isElement(node)) { 39 | els.push(node); 40 | } 41 | 42 | if (node === subTree.anchor) { 43 | return els; 44 | } 45 | 46 | node = node.nextSibling; 47 | } 48 | } 49 | 50 | // no elements found 51 | return els; 52 | } 53 | -------------------------------------------------------------------------------- /src/utils/helpers.ts: -------------------------------------------------------------------------------- 1 | export const isFunction = (val: any) => typeof val === 'function'; 2 | export const isObject = (val: any) => val !== null && typeof val === 'object'; 3 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export { default as createHydrationCleanup } from './create-hydration-cleanup'; 2 | export { default as createHydrationPromise } from './create-hydration-promise'; 3 | export { default as createHydrationObserver } from './create-hydration-observer'; 4 | export { default as createHydrationWrapper } from './create-hydration-wrapper'; 5 | export { default as trackDepsOnRender } from './track-deps-on-render'; 6 | export { default as waitForAsyncComponents } from './wait-for-async-components'; 7 | export { default as getRootElements } from './get-root-elements'; 8 | export { default as ensureParentHasSubTreeEl } from './ensure-parent-has-subtree-el'; 9 | export { default as traverseChildren } from './traverse-children'; 10 | export * from './helpers'; 11 | -------------------------------------------------------------------------------- /src/utils/track-deps-on-render.ts: -------------------------------------------------------------------------------- 1 | import type { ComponentInternalInstance, VNodeChild } from 'vue'; 2 | 3 | export default function trackDepsOnRender( 4 | instance: ComponentInternalInstance & { 5 | render: () => VNodeChild; 6 | } 7 | ) { 8 | const componentUpdateFn = instance.effect.fn; 9 | const originalRender = instance.render; 10 | 11 | instance.render = (...args) => { 12 | instance.effect.fn = () => originalRender(...args); 13 | 14 | const result = instance.effect.run() as VNodeChild; 15 | 16 | /** 17 | * Restore render and effect functions 18 | */ 19 | instance.effect.fn = componentUpdateFn; 20 | instance.render = originalRender; 21 | 22 | return result; 23 | }; 24 | } 25 | -------------------------------------------------------------------------------- /src/utils/traverse-children.spec.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable vue/one-component-per-file */ 2 | import { defineComponent, getCurrentInstance, h, onMounted } from 'vue'; 3 | 4 | import { withSSRSetup } from '../../test/utils'; 5 | 6 | import traverseChildren from './traverse-children'; 7 | 8 | beforeEach(() => { 9 | document.body.innerHTML = ''; 10 | }); 11 | 12 | it('should traverse slots children with single element', async () => { 13 | let foundSlotVnode = 0; 14 | 15 | const spyFn = vi.fn((vnode) => { 16 | if (vnode.type === 'p') { 17 | foundSlotVnode += 1; 18 | } 19 | }); 20 | 21 | const Child = defineComponent({ 22 | setup(_, { slots }) { 23 | return () => slots.default && slots.default(); 24 | }, 25 | }); 26 | 27 | await withSSRSetup(() => { 28 | const instance = getCurrentInstance()!; 29 | 30 | onMounted(() => { 31 | traverseChildren(instance.subTree, spyFn); 32 | }); 33 | 34 | return () => h('div', h(Child, null, { default: () => h('p', 'foo') })); 35 | }); 36 | 37 | // div, Child component, p 38 | expect(spyFn).toHaveBeenCalledTimes(3); 39 | // p 40 | expect(foundSlotVnode).toBe(1); 41 | }); 42 | 43 | it('should traverse slots children with multiple elements', async () => { 44 | let foundSlotVnode = 0; 45 | 46 | const spyFn = vi.fn((vnode) => { 47 | if (vnode.type === 'p') { 48 | foundSlotVnode += 1; 49 | } 50 | }); 51 | 52 | const Child = defineComponent({ 53 | setup(_, { slots }) { 54 | return () => slots.default && slots.default(); 55 | }, 56 | }); 57 | 58 | await withSSRSetup(() => { 59 | const instance = getCurrentInstance()!; 60 | 61 | onMounted(() => { 62 | traverseChildren(instance.subTree, spyFn); 63 | }); 64 | 65 | return () => 66 | h( 67 | 'div', 68 | h(Child, null, { default: () => [h('p', 'foo'), h('p', 'bar')] }) 69 | ); 70 | }); 71 | 72 | // div, Child component, p, p 73 | expect(spyFn).toHaveBeenCalledTimes(4); 74 | // p, p 75 | expect(foundSlotVnode).toBe(2); 76 | }); 77 | -------------------------------------------------------------------------------- /src/utils/traverse-children.ts: -------------------------------------------------------------------------------- 1 | import type { ExtendedConcreteComponent } from 'src/composables/useLazyHydration'; 2 | import { isVNode, type Slots, type VNode, type VNodeChild } from 'vue'; 3 | import { isFunction, isObject } from './helpers'; 4 | 5 | export default function traverseChildren( 6 | vnode: 7 | | (VNode & { 8 | type: ExtendedConcreteComponent; 9 | }) 10 | | VNodeChild, 11 | fn: ( 12 | vnode: VNode & { 13 | type: ExtendedConcreteComponent; 14 | } 15 | ) => void 16 | ) { 17 | if (!isVNode(vnode)) { 18 | return; 19 | } 20 | 21 | fn( 22 | vnode as VNode & { 23 | type: ExtendedConcreteComponent; 24 | } 25 | ); 26 | 27 | if (vnode.children === null) { 28 | return; 29 | } 30 | 31 | if (Array.isArray(vnode.children)) { 32 | vnode.children.forEach((child) => traverseChildren(child, fn)); 33 | 34 | return; 35 | } 36 | 37 | if (isObject(vnode.children)) { 38 | Object.keys(vnode.children).forEach((slotName) => { 39 | if (!isFunction((vnode.children as Slots)[slotName])) { 40 | return; 41 | } 42 | 43 | const slotContent = (vnode.children as Slots)[slotName]!(); 44 | 45 | if (Array.isArray(slotContent)) { 46 | slotContent.forEach((child) => traverseChildren(child, fn)); 47 | 48 | return; 49 | } 50 | 51 | traverseChildren(slotContent, fn); 52 | }); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/utils/wait-for-async-components.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-underscore-dangle */ 2 | 3 | import type { ExtendedConcreteComponent } from 'src/composables/useLazyHydration'; 4 | import type { ComponentInternalInstance, VNode } from 'vue'; 5 | import traverseChildren from './traverse-children'; 6 | 7 | function isAsyncWrapper( 8 | vnode: 9 | | VNode & { 10 | type: ExtendedConcreteComponent; 11 | } 12 | ) { 13 | return ( 14 | vnode.type?.__asyncLoader && vnode.type?.name === 'AsyncComponentWrapper' 15 | ); 16 | } 17 | 18 | export default function waitForAsyncComponents( 19 | { subTree }: ComponentInternalInstance, 20 | cb: () => void 21 | ) { 22 | const promises: Promise[] = []; 23 | 24 | traverseChildren(subTree, (vnode) => { 25 | if (isAsyncWrapper(vnode)) { 26 | promises.push(vnode.type.__asyncLoader!()); 27 | } 28 | }); 29 | 30 | if (promises.length > 0) { 31 | // eslint-disable-next-line no-void 32 | void Promise.all(promises).then(cb); 33 | 34 | return; 35 | } 36 | 37 | cb(); 38 | } 39 | -------------------------------------------------------------------------------- /src/wrappers/hydrate-never.spec.ts: -------------------------------------------------------------------------------- 1 | import { defineComponent, h, onUpdated } from 'vue'; 2 | import { flushPromises } from '@vue/test-utils'; 3 | 4 | import { withSSRSetup, triggerEvent } from '../../test/utils'; 5 | 6 | import { hydrateNever } from '.'; 7 | 8 | beforeEach(() => { 9 | document.body.innerHTML = ''; 10 | }); 11 | 12 | it('should never hydrate and load the component', async () => { 13 | const spyClick = vi.fn(); 14 | const spyUpdated = vi.fn(); 15 | 16 | const WrappedComp = hydrateNever( 17 | defineComponent({ 18 | setup() { 19 | onUpdated(spyUpdated); 20 | 21 | return () => h('button', { onClick: spyClick }, 'foo'); 22 | }, 23 | }) 24 | ); 25 | 26 | const { container } = await withSSRSetup(() => () => h(WrappedComp)); 27 | 28 | // hydration not complete yet 29 | triggerEvent('click', container.querySelector('button')); 30 | expect(spyClick).not.toHaveBeenCalled(); 31 | 32 | // should not be hydrated 33 | await flushPromises(); 34 | triggerEvent('click', container.querySelector('button')); 35 | expect(spyClick).not.toHaveBeenCalled(); 36 | expect(spyUpdated).not.toHaveBeenCalled(); 37 | expect('Hydration node mismatch').not.toHaveBeenWarned(); 38 | }); 39 | 40 | it('should never hydrate and load the component (with function)', async () => { 41 | const spyClick = vi.fn(); 42 | const spyUpdated = vi.fn(); 43 | 44 | const WrappedComp = hydrateNever(() => 45 | Promise.resolve({ 46 | setup() { 47 | onUpdated(spyUpdated); 48 | 49 | return () => h('button', { onClick: spyClick }, 'foo'); 50 | }, 51 | }) 52 | ); 53 | 54 | const { container } = await withSSRSetup(() => () => h(WrappedComp)); 55 | 56 | // hydration not complete yet 57 | triggerEvent('click', container.querySelector('button')); 58 | expect(spyClick).not.toHaveBeenCalled(); 59 | 60 | // should not be hydrated 61 | await flushPromises(); 62 | triggerEvent('click', container.querySelector('button')); 63 | expect(spyClick).not.toHaveBeenCalled(); 64 | expect(spyUpdated).not.toHaveBeenCalled(); 65 | expect('Hydration node mismatch').not.toHaveBeenWarned(); 66 | }); 67 | -------------------------------------------------------------------------------- /src/wrappers/hydrate-never.ts: -------------------------------------------------------------------------------- 1 | import type { AsyncComponentLoader, Component } from 'vue'; 2 | import { createHydrationWrapper } from '../utils'; 3 | 4 | /** 5 | * @public Wrap a Vue.js component in a renderless component so that it is never hydrated. 6 | */ 7 | export default function hydrateNever( 8 | source: Component | AsyncComponentLoader 9 | ): Component { 10 | return createHydrationWrapper(source, () => {}); 11 | } 12 | -------------------------------------------------------------------------------- /src/wrappers/hydrate-on-interaction.spec.ts: -------------------------------------------------------------------------------- 1 | import { h, onUpdated } from 'vue'; 2 | import { flushPromises } from '@vue/test-utils'; 3 | 4 | import { withSSRSetup, triggerEvent } from '../../test/utils'; 5 | 6 | import { hydrateOnInteraction } from '.'; 7 | 8 | beforeEach(() => { 9 | document.body.innerHTML = ''; 10 | }); 11 | 12 | it('should load the wrapped component just before hydration on interaction', async () => { 13 | const spyClick = vi.fn(); 14 | const spyUpdated = vi.fn(); 15 | 16 | const WrappedComp = hydrateOnInteraction({ 17 | setup() { 18 | onUpdated(spyUpdated); 19 | 20 | return () => h('button', { onClick: spyClick }, 'foo'); 21 | }, 22 | }); 23 | 24 | const { container } = await withSSRSetup(() => () => h(WrappedComp)); 25 | 26 | // hydration not complete yet 27 | triggerEvent('click', container.querySelector('button')); 28 | expect(spyClick).not.toHaveBeenCalled(); 29 | 30 | // trigger hydration and wait for it to complete 31 | triggerEvent('focus', container.querySelector('button')); 32 | await flushPromises(); 33 | 34 | // should be hydrated now 35 | triggerEvent('click', container.querySelector('button')); 36 | expect(spyClick).toHaveBeenCalledOnce(); 37 | expect(spyUpdated).not.toHaveBeenCalled(); 38 | expect('Hydration node mismatch').not.toHaveBeenWarned(); 39 | }); 40 | 41 | it('should load the wrapped component just before hydration on interaction (with function)', async () => { 42 | const spyClick = vi.fn(); 43 | const spyUpdated = vi.fn(); 44 | 45 | const WrappedComp = hydrateOnInteraction(() => 46 | Promise.resolve({ 47 | setup() { 48 | onUpdated(spyUpdated); 49 | 50 | return () => h('button', { onClick: spyClick }, 'foo'); 51 | }, 52 | }) 53 | ); 54 | 55 | const { container } = await withSSRSetup(() => () => h(WrappedComp)); 56 | 57 | // hydration not complete yet 58 | triggerEvent('click', container.querySelector('button')); 59 | expect(spyClick).not.toHaveBeenCalled(); 60 | 61 | // trigger hydration and wait for it to complete 62 | triggerEvent('focus', container.querySelector('button')); 63 | await flushPromises(); 64 | 65 | // should be hydrated now 66 | triggerEvent('click', container.querySelector('button')); 67 | expect(spyClick).toHaveBeenCalledOnce(); 68 | expect(spyUpdated).not.toHaveBeenCalled(); 69 | expect('Hydration node mismatch').not.toHaveBeenWarned(); 70 | }); 71 | -------------------------------------------------------------------------------- /src/wrappers/hydrate-on-interaction.ts: -------------------------------------------------------------------------------- 1 | import type { AsyncComponentLoader, Component } from 'vue'; 2 | import { useHydrateOnInteraction } from '../composables'; 3 | import { createHydrationWrapper } from '../utils'; 4 | 5 | /** 6 | * @public Wrap a component in a renderless component so that it is hydrated when a specified HTML event occurs on one of its elements. 7 | */ 8 | export default function hydrateOnInteraction( 9 | source: Component | AsyncComponentLoader, 10 | events: (keyof HTMLElementEventMap)[] = ['focus'] 11 | ) { 12 | return createHydrationWrapper(source, (result) => { 13 | useHydrateOnInteraction(result, events); 14 | }); 15 | } 16 | -------------------------------------------------------------------------------- /src/wrappers/hydrate-when-idle.spec.ts: -------------------------------------------------------------------------------- 1 | import { h, onUpdated } from 'vue'; 2 | import { flushPromises } from '@vue/test-utils'; 3 | 4 | import { ensureMocksReset, requestIdleCallback } from '../../test/dom-mocks'; 5 | import { withSSRSetup, triggerEvent } from '../../test/utils'; 6 | 7 | import { hydrateWhenIdle } from '.'; 8 | 9 | beforeEach(() => { 10 | document.body.innerHTML = ''; 11 | }); 12 | 13 | afterEach(() => { 14 | ensureMocksReset(); 15 | }); 16 | 17 | it('should load the wrapped component just before hydration when browser is idle', async () => { 18 | const spyClick = vi.fn(); 19 | const spyUpdated = vi.fn(); 20 | requestIdleCallback.mock(); 21 | 22 | const WrappedComp = hydrateWhenIdle({ 23 | setup() { 24 | onUpdated(spyUpdated); 25 | 26 | return () => h('button', { onClick: spyClick }, 'foo'); 27 | }, 28 | }); 29 | 30 | const { container } = await withSSRSetup(() => () => h(WrappedComp)); 31 | 32 | // hydration not complete yet 33 | triggerEvent('click', container.querySelector('button')); 34 | expect(spyClick).not.toHaveBeenCalled(); 35 | 36 | // trigger hydration and wait for it to complete 37 | requestIdleCallback.runIdleCallbacks(); 38 | await flushPromises(); 39 | 40 | // should be hydrated now 41 | triggerEvent('click', container.querySelector('button')); 42 | expect(spyClick).toHaveBeenCalledOnce(); 43 | expect(spyUpdated).not.toHaveBeenCalled(); 44 | expect('Hydration node mismatch').not.toHaveBeenWarned(); 45 | 46 | requestIdleCallback.restore(); 47 | }); 48 | 49 | it('should load the wrapped component just before hydration when browser is idle (with function)', async () => { 50 | const spyClick = vi.fn(); 51 | const spyUpdated = vi.fn(); 52 | requestIdleCallback.mock(); 53 | 54 | const WrappedComp = hydrateWhenIdle(() => 55 | Promise.resolve({ 56 | setup() { 57 | onUpdated(spyUpdated); 58 | 59 | return () => h('button', { onClick: spyClick }, 'foo'); 60 | }, 61 | }) 62 | ); 63 | 64 | const { container } = await withSSRSetup(() => () => h(WrappedComp)); 65 | 66 | // hydration not complete yet 67 | triggerEvent('click', container.querySelector('button')); 68 | expect(spyClick).not.toHaveBeenCalled(); 69 | 70 | // trigger hydration and wait for it to complete 71 | requestIdleCallback.runIdleCallbacks(); 72 | await flushPromises(); 73 | 74 | // should be hydrated now 75 | triggerEvent('click', container.querySelector('button')); 76 | expect(spyClick).toHaveBeenCalledOnce(); 77 | expect(spyUpdated).not.toHaveBeenCalled(); 78 | expect('Hydration node mismatch').not.toHaveBeenWarned(); 79 | 80 | requestIdleCallback.restore(); 81 | }); 82 | -------------------------------------------------------------------------------- /src/wrappers/hydrate-when-idle.ts: -------------------------------------------------------------------------------- 1 | import type { AsyncComponentLoader, Component } from 'vue'; 2 | import { useHydrateWhenIdle } from '../composables'; 3 | import { createHydrationWrapper } from '../utils'; 4 | 5 | /** 6 | * @public Wrap a component in a renderless component so that it is hydrated when the browser is idle. 7 | */ 8 | export default function hydrateWhenIdle( 9 | source: Component | AsyncComponentLoader, 10 | timeout = 2000 11 | ) { 12 | return createHydrationWrapper(source, (result) => { 13 | useHydrateWhenIdle(result, timeout); 14 | }); 15 | } 16 | -------------------------------------------------------------------------------- /src/wrappers/hydrate-when-triggered.spec.ts: -------------------------------------------------------------------------------- 1 | import { h, onUpdated, ref } from 'vue'; 2 | import { flushPromises } from '@vue/test-utils'; 3 | 4 | import { withSSRSetup, triggerEvent } from '../../test/utils'; 5 | 6 | import { hydrateWhenTriggered } from '.'; 7 | 8 | beforeEach(() => { 9 | document.body.innerHTML = ''; 10 | }); 11 | 12 | it('should load the wrapped component just before hydration when triggered', async () => { 13 | const spyClick = vi.fn(); 14 | const spyUpdated = vi.fn(); 15 | const trigger = ref(false); 16 | 17 | const WrappedComp = hydrateWhenTriggered( 18 | { 19 | setup() { 20 | onUpdated(spyUpdated); 21 | 22 | return () => h('button', { onClick: spyClick }, 'foo'); 23 | }, 24 | }, 25 | trigger 26 | ); 27 | 28 | const { container } = await withSSRSetup(() => () => h(WrappedComp)); 29 | 30 | // hydration not complete yet 31 | triggerEvent('click', container.querySelector('button')); 32 | expect(spyClick).not.toHaveBeenCalled(); 33 | 34 | // trigger hydration and wait for it to complete 35 | trigger.value = true; 36 | await flushPromises(); 37 | 38 | // should be hydrated now 39 | triggerEvent('click', container.querySelector('button')); 40 | expect(spyClick).toHaveBeenCalledOnce(); 41 | expect(spyUpdated).not.toHaveBeenCalled(); 42 | expect('Hydration node mismatch').not.toHaveBeenWarned(); 43 | }); 44 | 45 | it('should load the wrapped component just before hydration when triggered (with function)', async () => { 46 | const spyClick = vi.fn(); 47 | const spyUpdated = vi.fn(); 48 | const trigger = ref(false); 49 | 50 | const WrappedComp = hydrateWhenTriggered( 51 | () => 52 | Promise.resolve({ 53 | setup() { 54 | onUpdated(spyUpdated); 55 | 56 | return () => h('button', { onClick: spyClick }, 'foo'); 57 | }, 58 | }), 59 | trigger 60 | ); 61 | 62 | const { container } = await withSSRSetup(() => () => h(WrappedComp)); 63 | 64 | // hydration not complete yet 65 | triggerEvent('click', container.querySelector('button')); 66 | expect(spyClick).not.toHaveBeenCalled(); 67 | 68 | // trigger hydration and wait for it to complete 69 | trigger.value = true; 70 | await flushPromises(); 71 | 72 | // should be hydrated now 73 | triggerEvent('click', container.querySelector('button')); 74 | expect(spyClick).toHaveBeenCalledOnce(); 75 | expect(spyUpdated).not.toHaveBeenCalled(); 76 | expect('Hydration node mismatch').not.toHaveBeenWarned(); 77 | }); 78 | -------------------------------------------------------------------------------- /src/wrappers/hydrate-when-triggered.ts: -------------------------------------------------------------------------------- 1 | import type { AsyncComponentLoader, Component, Ref } from 'vue'; 2 | import { useHydrateWhenTriggered } from '../composables'; 3 | import { createHydrationWrapper } from '../utils'; 4 | 5 | /** 6 | * @public Wrap a component in a renderless component so that it is hydrated when triggered. 7 | */ 8 | export default function hydrateWhenTriggered( 9 | source: Component | AsyncComponentLoader, 10 | triggered: Ref | (() => boolean) 11 | ) { 12 | return createHydrationWrapper(source, (result) => { 13 | useHydrateWhenTriggered(result, triggered); 14 | }); 15 | } 16 | -------------------------------------------------------------------------------- /src/wrappers/hydrate-when-visible.spec.ts: -------------------------------------------------------------------------------- 1 | import { h, onUpdated } from 'vue'; 2 | import { flushPromises } from '@vue/test-utils'; 3 | 4 | import { ensureMocksReset, intersectionObserver } from '../../test/dom-mocks'; 5 | import { withSSRSetup, triggerEvent } from '../../test/utils'; 6 | 7 | import { hydrateWhenVisible } from '.'; 8 | 9 | beforeEach(() => { 10 | document.body.innerHTML = ''; 11 | }); 12 | 13 | afterEach(() => { 14 | ensureMocksReset(); 15 | }); 16 | 17 | it('should load the wrapped component just before hydration when component element is visible', async () => { 18 | const spyClick = vi.fn(); 19 | const spyUpdated = vi.fn(); 20 | intersectionObserver.mock(); 21 | 22 | const WrappedComp = hydrateWhenVisible({ 23 | setup() { 24 | onUpdated(spyUpdated); 25 | 26 | return () => h('button', { onClick: spyClick }, 'foo'); 27 | }, 28 | }); 29 | 30 | const { container } = await withSSRSetup(() => () => h(WrappedComp)); 31 | 32 | // hydration not complete yet 33 | triggerEvent('click', container.querySelector('button')); 34 | expect(spyClick).not.toHaveBeenCalled(); 35 | 36 | // trigger hydration and wait for it to complete 37 | intersectionObserver.simulate({ 38 | target: container.querySelector('button') || undefined, 39 | isIntersecting: true, 40 | }); 41 | await flushPromises(); 42 | 43 | // should be hydrated now 44 | triggerEvent('click', container.querySelector('button')); 45 | expect(spyClick).toHaveBeenCalledOnce(); 46 | expect(spyUpdated).not.toHaveBeenCalled(); 47 | expect('Hydration node mismatch').not.toHaveBeenWarned(); 48 | 49 | intersectionObserver.restore(); 50 | }); 51 | 52 | it('should load the wrapped component just before hydration when component element is visible (with function)', async () => { 53 | const spyClick = vi.fn(); 54 | const spyUpdated = vi.fn(); 55 | intersectionObserver.mock(); 56 | 57 | const WrappedComp = hydrateWhenVisible(() => 58 | Promise.resolve({ 59 | setup() { 60 | onUpdated(spyUpdated); 61 | 62 | return () => h('button', { onClick: spyClick }, 'foo'); 63 | }, 64 | }) 65 | ); 66 | 67 | const { container } = await withSSRSetup(() => () => h(WrappedComp)); 68 | 69 | // hydration not complete yet 70 | triggerEvent('click', container.querySelector('button')); 71 | expect(spyClick).not.toHaveBeenCalled(); 72 | 73 | // trigger hydration and wait for it to complete 74 | intersectionObserver.simulate({ 75 | target: container.querySelector('button') || undefined, 76 | isIntersecting: true, 77 | }); 78 | await flushPromises(); 79 | 80 | // should be hydrated now 81 | triggerEvent('click', container.querySelector('button')); 82 | expect(spyClick).toHaveBeenCalledOnce(); 83 | expect(spyUpdated).not.toHaveBeenCalled(); 84 | expect('Hydration node mismatch').not.toHaveBeenWarned(); 85 | 86 | intersectionObserver.restore(); 87 | }); 88 | -------------------------------------------------------------------------------- /src/wrappers/hydrate-when-visible.ts: -------------------------------------------------------------------------------- 1 | import type { AsyncComponentLoader, Component } from 'vue'; 2 | import { useHydrateWhenVisible } from '../composables'; 3 | import { createHydrationWrapper } from '../utils'; 4 | 5 | /** 6 | * @public Wrap a Vue.js component in a renderless component so that it is hydrated when one of the component's root elements is visible. 7 | */ 8 | export default function hydrateWhenVisible( 9 | source: Component | AsyncComponentLoader, 10 | observerOpts?: IntersectionObserverInit 11 | ) { 12 | return createHydrationWrapper(source, (result) => { 13 | useHydrateWhenVisible(result, observerOpts); 14 | }); 15 | } 16 | -------------------------------------------------------------------------------- /src/wrappers/index.ts: -------------------------------------------------------------------------------- 1 | export { default as hydrateNever } from './hydrate-never'; 2 | export { default as hydrateWhenIdle } from './hydrate-when-idle'; 3 | export { default as hydrateWhenVisible } from './hydrate-when-visible'; 4 | export { default as hydrateOnInteraction } from './hydrate-on-interaction'; 5 | export { default as hydrateWhenTriggered } from './hydrate-when-triggered'; 6 | -------------------------------------------------------------------------------- /test/dom-mocks/index.ts: -------------------------------------------------------------------------------- 1 | import RequestIdleCallback from './request-idle-callback'; 2 | import IntersectionObserver from './intersection-observer'; 3 | 4 | export const requestIdleCallback = new RequestIdleCallback(); 5 | export const intersectionObserver = new IntersectionObserver(); 6 | 7 | const mocksToEnsureReset: { 8 | [key: string]: typeof requestIdleCallback | typeof intersectionObserver; 9 | } = { 10 | requestIdleCallback, 11 | intersectionObserver, 12 | }; 13 | 14 | export function ensureMocksReset() { 15 | Object.keys(mocksToEnsureReset).forEach((mockName) => { 16 | if (mocksToEnsureReset[mockName].isMocked()) { 17 | throw new Error( 18 | `You did not reset the mocked ${mockName}. Make sure to call ${mockName}.restore() after your tests have run.` 19 | ); 20 | } 21 | }); 22 | } 23 | -------------------------------------------------------------------------------- /test/dom-mocks/intersection-observer.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-classes-per-file */ 2 | 3 | interface Observer { 4 | source: unknown; 5 | target: Element; 6 | callback: IntersectionObserverCallback; 7 | options?: IntersectionObserverInit; 8 | } 9 | 10 | interface WindowWithObserver extends Window { 11 | intersectionObserver: Observer; 12 | } 13 | 14 | function normalizeEntry( 15 | entry: Partial, 16 | target: Element 17 | ): IntersectionObserverEntry { 18 | const isIntersecting = 19 | entry.isIntersecting == null 20 | ? Boolean(entry.intersectionRatio) 21 | : entry.isIntersecting; 22 | 23 | const intersectionRatio = entry.intersectionRatio || (isIntersecting ? 1 : 0); 24 | 25 | return { 26 | boundingClientRect: 27 | entry.boundingClientRect || target.getBoundingClientRect(), 28 | intersectionRatio, 29 | intersectionRect: entry.intersectionRect || target.getBoundingClientRect(), 30 | isIntersecting, 31 | rootBounds: entry.rootBounds || document.body.getBoundingClientRect(), 32 | target, 33 | time: entry.time || Date.now(), 34 | }; 35 | } 36 | 37 | export default class IntersectionObserverMock { 38 | observers: Observer[] = []; 39 | 40 | private isUsingMockIntersectionObserver = false; 41 | 42 | private isMockingUnsupported = false; 43 | 44 | private originalIntersectionObserver = (global as any).IntersectionObserver; 45 | 46 | private originalIntersectionObserverEntry = (global as any) 47 | .IntersectionObserverEntry; 48 | 49 | simulate( 50 | entry: 51 | | Partial 52 | | Partial[] 53 | ) { 54 | this.ensureMocked(); 55 | 56 | const arrayOfEntries = Array.isArray(entry) ? entry : [entry]; 57 | const targets = arrayOfEntries.map(({ target }) => target); 58 | const noCustomTargets = targets.every((target) => target == null); 59 | 60 | this.observers.forEach((observer) => { 61 | if (noCustomTargets || targets.includes(observer.target)) { 62 | observer.callback( 63 | arrayOfEntries.map((observerEntry) => 64 | normalizeEntry(observerEntry, observer.target) 65 | ), 66 | observer as any 67 | ); 68 | } 69 | }); 70 | } 71 | 72 | mock() { 73 | if (this.isUsingMockIntersectionObserver) { 74 | throw new Error( 75 | 'IntersectionObserver is already mocked, but you tried to mock it again.' 76 | ); 77 | } 78 | 79 | this.isUsingMockIntersectionObserver = true; 80 | 81 | const setObservers = (setter: (observers: Observer[]) => Observer[]) => { 82 | this.observers = setter(this.observers); 83 | }; 84 | 85 | ( 86 | global as any 87 | ).IntersectionObserverEntry = class IntersectionObserverEntry {}; 88 | Object.defineProperty( 89 | IntersectionObserverEntry.prototype, 90 | 'intersectionRatio', 91 | { 92 | get() { 93 | return 0; 94 | }, 95 | } 96 | ); 97 | 98 | (global as any).IntersectionObserver = class FakeIntersectionObserver { 99 | constructor( 100 | private callback: IntersectionObserverCallback, 101 | private options?: IntersectionObserverInit 102 | ) {} 103 | 104 | observe(target: Element) { 105 | setObservers((observers) => [ 106 | ...observers, 107 | { 108 | source: this, 109 | target, 110 | callback: this.callback, 111 | options: this.options, 112 | }, 113 | ]); 114 | } 115 | 116 | disconnect() { 117 | setObservers((observers) => 118 | observers.filter((observer) => observer.source !== this) 119 | ); 120 | } 121 | 122 | unobserve(target: Element) { 123 | setObservers((observers) => 124 | observers.filter( 125 | (observer) => 126 | !(observer.target === target && observer.source === this) 127 | ) 128 | ); 129 | } 130 | }; 131 | } 132 | 133 | mockAsUnsupported() { 134 | if (this.isUsingMockIntersectionObserver) { 135 | throw new Error( 136 | 'intersectionObserver is already mocked, but you tried to mock it again.' 137 | ); 138 | } 139 | 140 | this.isUsingMockIntersectionObserver = true; 141 | this.isMockingUnsupported = true; 142 | 143 | const windowWithObserver: WindowWithObserver = window as any; 144 | 145 | this.originalIntersectionObserver = windowWithObserver.intersectionObserver; 146 | delete (windowWithObserver as any).intersectionObserver; 147 | } 148 | 149 | restore() { 150 | if (!this.isUsingMockIntersectionObserver) { 151 | throw new Error( 152 | 'IntersectionObserver is already real, but you tried to restore it again.' 153 | ); 154 | } 155 | 156 | (global as any).IntersectionObserver = this.originalIntersectionObserver; 157 | (global as any).IntersectionObserverEntry = 158 | this.originalIntersectionObserverEntry; 159 | 160 | this.isUsingMockIntersectionObserver = false; 161 | this.isMockingUnsupported = false; 162 | this.observers.length = 0; 163 | } 164 | 165 | isMocked() { 166 | return this.isUsingMockIntersectionObserver; 167 | } 168 | 169 | private ensureMocked() { 170 | if (!this.isUsingMockIntersectionObserver) { 171 | throw new Error( 172 | 'You must call intersectionObserver.mock() before interacting with the fake IntersectionObserver.' 173 | ); 174 | } 175 | 176 | if (this.isMockingUnsupported) { 177 | throw new Error( 178 | 'You have mocked intersectionObserver as unsupported. Call intersectionObserver.restore(), then intersectionObserver.mock() if you want to simulate intersection Observer.' 179 | ); 180 | } 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /test/dom-mocks/request-idle-callback.ts: -------------------------------------------------------------------------------- 1 | type RequestIdleCallbackHandle = any; 2 | 3 | interface RequestIdleCallbackOptions { 4 | timeout: number; 5 | } 6 | 7 | interface RequestIdleCallbackDeadline { 8 | readonly didTimeout: boolean; 9 | timeRemaining(): number; 10 | } 11 | 12 | type IdleCallback = (deadline: RequestIdleCallbackDeadline) => void; 13 | 14 | interface WindowWithRequestIdleCallback extends Window { 15 | requestIdleCallback( 16 | callback: IdleCallback, 17 | opts?: RequestIdleCallbackOptions 18 | ): RequestIdleCallbackHandle; 19 | cancelIdleCallback: (handle: RequestIdleCallbackHandle) => void; 20 | } 21 | 22 | export default class RequestIdleCallback { 23 | private isUsingMockIdleCallback = false; 24 | 25 | private isMockingUnsupported = false; 26 | 27 | private queued: { 28 | [key: string]: IdleCallback; 29 | } = {}; 30 | 31 | private originalRequestIdleCallback: any; 32 | 33 | private originalCancelIdleCallback: any; 34 | 35 | private currentIdleCallback = 0; 36 | 37 | mock() { 38 | if (this.isUsingMockIdleCallback) { 39 | throw new Error( 40 | 'requestIdleCallback is already mocked, but you tried to mock it again.' 41 | ); 42 | } 43 | 44 | this.isUsingMockIdleCallback = true; 45 | 46 | const windowWithIdle: WindowWithRequestIdleCallback = window as any; 47 | 48 | this.originalRequestIdleCallback = windowWithIdle.requestIdleCallback; 49 | windowWithIdle.requestIdleCallback = this.requestIdleCallback; 50 | 51 | this.originalCancelIdleCallback = windowWithIdle.cancelIdleCallback; 52 | windowWithIdle.cancelIdleCallback = this.cancelIdleCallback; 53 | } 54 | 55 | mockAsUnsupported() { 56 | if (this.isUsingMockIdleCallback) { 57 | throw new Error( 58 | 'requestIdleCallback is already mocked, but you tried to mock it again.' 59 | ); 60 | } 61 | 62 | this.isUsingMockIdleCallback = true; 63 | this.isMockingUnsupported = true; 64 | 65 | const windowWithIdle: WindowWithRequestIdleCallback = window as any; 66 | 67 | this.originalRequestIdleCallback = windowWithIdle.requestIdleCallback; 68 | delete (windowWithIdle as any).requestIdleCallback; 69 | 70 | this.originalCancelIdleCallback = windowWithIdle.cancelIdleCallback; 71 | delete (windowWithIdle as any).cancelIdleCallback; 72 | } 73 | 74 | restore() { 75 | if (!this.isUsingMockIdleCallback) { 76 | throw new Error( 77 | 'requestIdleCallback is already real, but you tried to restore it again.' 78 | ); 79 | } 80 | 81 | if (Object.keys(this.queued).length > 0) { 82 | // eslint-disable-next-line no-console 83 | console.warn( 84 | 'You are restoring requestIdleCallback, but some idle callbacks have not been run. You can run requestIdleCallback.cancelIdleCallback() to clear them all and avoid this warning.' 85 | ); 86 | 87 | this.cancelIdleCallbacks(); 88 | } 89 | 90 | this.isUsingMockIdleCallback = false; 91 | this.isMockingUnsupported = false; 92 | 93 | if (this.originalRequestIdleCallback) { 94 | (window as any).requestIdleCallback = this.originalRequestIdleCallback; 95 | } else { 96 | delete (window as any).requestIdleCallback; 97 | } 98 | 99 | if (this.originalCancelIdleCallback) { 100 | (window as any).cancelIdleCallback = this.originalCancelIdleCallback; 101 | } else { 102 | delete (window as any).cancelIdleCallback; 103 | } 104 | } 105 | 106 | isMocked() { 107 | return this.isUsingMockIdleCallback; 108 | } 109 | 110 | runIdleCallbacks(timeRemaining = Infinity, didTimeout = false) { 111 | this.ensureIdleCallbackIsMock(); 112 | 113 | // We need to do it this way so that frames that queue other frames 114 | // don't get removed 115 | Object.keys(this.queued).forEach((frame: any) => { 116 | const callback = this.queued[frame]; 117 | delete this.queued[frame]; 118 | callback({ timeRemaining: () => timeRemaining, didTimeout }); 119 | }); 120 | } 121 | 122 | cancelIdleCallbacks() { 123 | this.ensureIdleCallbackIsMock(); 124 | 125 | Object.keys(this.queued).forEach((id) => { 126 | this.cancelIdleCallback(id); 127 | }); 128 | } 129 | 130 | private requestIdleCallback = ( 131 | callback: IdleCallback 132 | ): ReturnType => { 133 | this.currentIdleCallback += 1; 134 | this.queued[this.currentIdleCallback] = callback; 135 | return this.currentIdleCallback; 136 | }; 137 | 138 | private cancelIdleCallback = ( 139 | callback: ReturnType 140 | ) => { 141 | delete this.queued[callback]; 142 | }; 143 | 144 | private ensureIdleCallbackIsMock() { 145 | if (!this.isUsingMockIdleCallback) { 146 | throw new Error( 147 | 'You must call requestIdleCallback.mock() before interacting with the mock request- or cancel- IdleCallback methods.' 148 | ); 149 | } 150 | 151 | if (this.isMockingUnsupported) { 152 | throw new Error( 153 | 'You have mocked requestIdleCallback as unsupported. Call requestIdleCallback.restore(), then requestIdleCallback.mock() if you want to simulate idle callbacks.' 154 | ); 155 | } 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /test/setupVitestEnv.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-use-before-define */ 2 | import type { SpyInstance } from 'vitest'; 3 | 4 | declare global { 5 | namespace Vi { 6 | interface Assertion { 7 | toHaveBeenWarned(): void; 8 | toHaveBeenWarnedLast(): void; 9 | toHaveBeenWarnedTimes(n: number): void; 10 | } 11 | } 12 | } 13 | 14 | expect.extend({ 15 | toHaveBeenWarned(received) { 16 | asserted.add(received); 17 | const passed = warn.mock.calls.some((args) => args[0].includes(received)); 18 | if (passed) { 19 | return { 20 | pass: true, 21 | message: () => `expected "${received}" not to have been warned.`, 22 | }; 23 | } 24 | const msgs = warn.mock.calls.map((args) => args[0]).join('\n - '); 25 | return { 26 | pass: false, 27 | message: () => 28 | `expected "${received}" to have been warned${ 29 | msgs.length 30 | ? `.\n\nActual messages:\n\n - ${msgs}` 31 | : ' but no warning was recorded.' 32 | }`, 33 | }; 34 | }, 35 | 36 | toHaveBeenWarnedLast(received) { 37 | asserted.add(received); 38 | const passed = 39 | warn.mock.calls[warn.mock.calls.length - 1][0].includes(received); 40 | if (passed) { 41 | return { 42 | pass: true, 43 | message: () => `expected "${received}" not to have been warned last.`, 44 | }; 45 | } 46 | const msgs = warn.mock.calls.map((args) => args[0]).join('\n - '); 47 | return { 48 | pass: false, 49 | message: () => 50 | `expected "${received}" to have been warned last.\n\nActual messages:\n\n - ${msgs}`, 51 | }; 52 | }, 53 | 54 | toHaveBeenWarnedTimes(received, n) { 55 | asserted.add(received); 56 | let found = 0; 57 | warn.mock.calls.forEach((args) => { 58 | if (args[0].includes(received)) { 59 | found += 1; 60 | } 61 | }); 62 | 63 | if (found === n) { 64 | return { 65 | pass: true, 66 | message: () => `expected "${received}" to have been warned ${n} times.`, 67 | }; 68 | } 69 | return { 70 | pass: false, 71 | message: () => 72 | `expected "${received}" to have been warned ${n} times but got ${found}.`, 73 | }; 74 | }, 75 | }); 76 | 77 | let warn: SpyInstance; 78 | const asserted = new Set(); 79 | 80 | beforeEach(() => { 81 | asserted.clear(); 82 | warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); 83 | }); 84 | 85 | afterEach(() => { 86 | const assertedArray = Array.from(asserted); 87 | const nonAssertedWarnings = warn.mock.calls 88 | .map((args) => args[0]) 89 | .filter( 90 | (received) => 91 | !assertedArray.some((assertedMsg) => received.includes(assertedMsg)) 92 | ); 93 | 94 | warn.mockRestore(); 95 | 96 | if (nonAssertedWarnings.length) { 97 | throw new Error( 98 | `test case threw unexpected warnings:\n - ${nonAssertedWarnings.join( 99 | '\n - ' 100 | )}` 101 | ); 102 | } 103 | }); 104 | -------------------------------------------------------------------------------- /test/utils.ts: -------------------------------------------------------------------------------- 1 | import { createSSRApp, defineComponent } from 'vue'; 2 | import { renderToString } from '@vue/server-renderer'; 3 | 4 | const originalWindow = window; 5 | 6 | export function createApp( 7 | setupFn: (isClient: boolean) => void, 8 | isClient = false 9 | ) { 10 | const App = defineComponent({ 11 | setup() { 12 | return setupFn(isClient); 13 | }, 14 | }); 15 | 16 | return createSSRApp(App); 17 | } 18 | 19 | export async function withSSRSetup(setupFn: (isClient: boolean) => void) { 20 | // make sure window is undefined at server-side 21 | vi.stubGlobal('window', undefined); 22 | 23 | // render at server-side 24 | const html = await renderToString(createApp(setupFn)); 25 | 26 | // restore window for client-side 27 | vi.stubGlobal('window', originalWindow); 28 | 29 | const container = document.createElement('div'); 30 | 31 | container.innerHTML = html; 32 | 33 | document.append(container); 34 | 35 | // client-side app 36 | const app = createApp(setupFn, true); 37 | 38 | // hydrate application 39 | app.mount(container); 40 | 41 | return { app, container }; 42 | } 43 | 44 | export const triggerEvent = (type: string, el: Element | null) => { 45 | if (el === null) { 46 | return; 47 | } 48 | 49 | const event = new Event(type, { bubbles: true }); 50 | 51 | el.dispatchEvent(event); 52 | }; 53 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@vue/tsconfig/tsconfig.json", 3 | "compilerOptions": { 4 | "target": "es2020", 5 | "resolveJsonModule": true, 6 | "baseUrl": "./", 7 | "outDir": "dist", 8 | "declaration": true, 9 | "declarationDir": "./temp", 10 | "noEmitOnError": true, 11 | "importsNotUsedAsValues": "remove", 12 | "preserveValueImports": false, 13 | "sourceMap": false, 14 | "strict": true, 15 | "noUnusedLocals": true, 16 | "noImplicitReturns": true, 17 | "types": ["node", "vite/client", "vitest/globals"] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /tsconfig.types.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["src"], 4 | "exclude": ["./**/*.spec.ts"] 5 | } 6 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import { defineConfig } from 'vite'; 3 | import vue from '@vitejs/plugin-vue'; 4 | 5 | import { name as packageName } from './package.json'; 6 | import devSSR from './plugin-dev-server'; 7 | 8 | // https://vitejs.dev/config/ 9 | export default defineConfig(({ command }) => ({ 10 | plugins: [vue(), devSSR()], 11 | publicDir: command === 'build' ? false : 'public', 12 | define: { 13 | __DEV__: `${ 14 | command === 'build' 15 | ? `process.env.NODE_ENV === 'development'` 16 | : 'import.meta.env.DEV' 17 | }`, 18 | }, 19 | build: { 20 | lib: { 21 | entry: path.resolve(__dirname, 'src/index.ts'), 22 | name: packageName, 23 | }, 24 | rollupOptions: { 25 | external: ['vue'], 26 | treeshake: { 27 | moduleSideEffects: 'no-external', 28 | }, 29 | output: [ 30 | { 31 | format: 'cjs', 32 | entryFileNames: `${packageName}.cjs`, 33 | dir: 'dist', 34 | }, 35 | { 36 | format: 'es', 37 | dir: 'dist/esm', 38 | entryFileNames: ({ facadeModuleId }) => { 39 | if (facadeModuleId?.endsWith('src/index.ts')) { 40 | return `${packageName}.mjs`; 41 | } 42 | 43 | return '[name].mjs'; 44 | }, 45 | preserveModules: true, 46 | }, 47 | ], 48 | }, 49 | minify: false, 50 | sourcemap: false, 51 | }, 52 | test: { 53 | globals: true, 54 | environment: 'happy-dom', 55 | setupFiles: ['./test/setupVitestEnv.ts'], 56 | }, 57 | })); 58 | --------------------------------------------------------------------------------