├── .eslintrc.yaml ├── .github └── workflows │ └── test.yml ├── .gitignore ├── .prettierrc.cjs ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── rollup.config.js ├── src └── index.js └── test ├── components ├── my-component.riot └── with-loops.riot └── index.js /.eslintrc.yaml: -------------------------------------------------------------------------------- 1 | extends: 'eslint-config-riot' 2 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | push: 5 | branches: [main, dev] 6 | pull_request: 7 | branches: [main, dev] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | node-version: [18.x, 19.x] 16 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 17 | steps: 18 | - uses: actions/checkout@v2 19 | - name: Local Unit Test ${{ matrix.node-version }} 20 | uses: actions/setup-node@v1 21 | with: 22 | node-version: ${{ matrix.node-version }} 23 | - run: npm i 24 | - run: npm test 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | yarn.lock 36 | 37 | # Optional REPL history 38 | .node_repl_history 39 | 40 | # mac useless file 41 | .DS_Store 42 | 43 | # Editor generate files 44 | .idea 45 | 46 | # generated files 47 | /index.js 48 | /index.cjs 49 | -------------------------------------------------------------------------------- /.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = require('@riotjs/prettier-config') 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Riot.js 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 | # @riotjs/hydrate 2 | 3 | [![Build Status][ci-image]][ci-url] 4 | [![NPM version][npm-version-image]][npm-url] 5 | [![NPM downloads][npm-downloads-image]][npm-url] 6 | [![MIT License][license-image]][license-url] 7 | 8 | ## Installation 9 | 10 | ```bash 11 | npm i -S riot @riotjs/hydrate 12 | ``` 13 | 14 | ## Usage 15 | 16 | If you are using [`@riotjs/ssr`](https://github.com/riot/ssr) you might prefer hydrating your server side rendered HTML enhancing your application user experience. Your users will get initially the static HTML (generated via `@riotjs/ssr`) that will be enhanced only when javascript application will be loaded.
`@riotjs/hydrate` will allow you avoiding any perceivable application flickering or input fields focus loss when the javascript components will replace the static rendered markup. 17 | 18 | A good practice is to mount your Riot.js components **exactly with the same initial properties** on the server as on the client. 19 | 20 | ```js 21 | import hydrate from '@riotjs/hydrate' 22 | import MyComponent from './my-component.riot' 23 | 24 | const hydrateWithMyComponent = hydrate(MyComponent) 25 | 26 | // hydrate the SSR DOM contained in the #root element 27 | hydrateWithMyComponent( 28 | document.getElementById('root'), 29 | window.__INITIAL_APPLICATION_PROPS__, 30 | ) 31 | ``` 32 | 33 | ### Callbacks 34 | 35 | You can use the `onBeforeHydrate` and `onHydrated` callback in your components to setup your application internal state. Notice that these callbacks will be called only on the component hydrated and not on all its nested children components. 36 | 37 | ```html 38 | 39 | 49 | 50 | ``` 51 | 52 | ### Caveats 53 | 54 | The `hydrate` method will mount your components on a clone of your target node not yet in the DOM. If your component state relies on DOM computations like `get​Bounding​Client​Rect` and you don't want to use the `onHydrated` callback, you will need to use a `requestAnimationFrame` callback in your `onMounted` method to wait that your root node has replaced completely the initial static markup for example: 55 | 56 | ```html 57 | 58 | 68 | 69 | ``` 70 | 71 | [ci-image]: https://img.shields.io/github/actions/workflow/status/riot/hydrate/test.yml?style=flat-square 72 | [ci-url]: https://github.com/riot/hydrate/actions 73 | [license-image]: http://img.shields.io/badge/license-MIT-000000.svg?style=flat-square 74 | [license-url]: LICENSE 75 | [npm-version-image]: http://img.shields.io/npm/v/@riotjs/hydrate.svg?style=flat-square 76 | [npm-downloads-image]: http://img.shields.io/npm/dm/@riotjs/hydrate.svg?style=flat-square 77 | [npm-url]: https://npmjs.org/package/@riotjs/hydrate 78 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@riotjs/hydrate", 3 | "version": "9.0.0", 4 | "description": "Riot.js hydrate strategy for SSR applications", 5 | "type": "module", 6 | "main": "index.js", 7 | "exports": { 8 | "import": "./index.js", 9 | "require": "./index.cjs" 10 | }, 11 | "files": [ 12 | "index.cjs", 13 | "index.js" 14 | ], 15 | "scripts": { 16 | "prepublishOnly": "npm run build && npm test", 17 | "lint": "eslint src/*.js test/*.js rollup.config.js", 18 | "build": "rollup -c", 19 | "pretest": "npm run build", 20 | "test": "npm run lint && NODE_OPTIONS=\"--loader @riotjs/register\" mocha --exit test/*.js" 21 | }, 22 | "repository": { 23 | "type": "git", 24 | "url": "git+https://github.com/riot/hydrate.git" 25 | }, 26 | "keywords": [ 27 | "riot", 28 | "hydrate", 29 | "ssr", 30 | "Riot.js", 31 | "components" 32 | ], 33 | "peerDependencies": { 34 | "riot": "^6.0.0 || ^7.0.0 || ^9.0.0" 35 | }, 36 | "author": "Gianluca Guarini (https://gianlucaguarini.com)", 37 | "license": "MIT", 38 | "bugs": { 39 | "url": "https://github.com/riot/hydrate/issues" 40 | }, 41 | "homepage": "https://github.com/riot/hydrate#readme", 42 | "devDependencies": { 43 | "@riotjs/compiler": "^9.0.6", 44 | "@riotjs/prettier-config": "^1.1.0", 45 | "@riotjs/register": "^9.0.0", 46 | "@rollup/plugin-node-resolve": "*", 47 | "chai": "^4.3.10", 48 | "eslint": "^8.53.0", 49 | "eslint-config-riot": "^4.1.1", 50 | "jsdom": "22.1.0", 51 | "jsdom-global": "3.0.2", 52 | "mocha": "^10.2.0", 53 | "prettier": "^3.0.3", 54 | "riot": "^9.1.1", 55 | "rollup": "^4.3.0", 56 | "sinon": "^17.0.1", 57 | "sinon-chai": "^3.7.0" 58 | }, 59 | "dependencies": { 60 | "morphdom": "^2.7.1" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import { nodeResolve } from '@rollup/plugin-node-resolve' 2 | 3 | const globals = { 4 | riot: 'riot', 5 | } 6 | 7 | export default { 8 | input: 'src/index.js', 9 | plugins: [nodeResolve()], 10 | output: [ 11 | { 12 | name: 'hydrate', 13 | file: 'index.cjs', 14 | format: 'umd', 15 | globals, 16 | }, 17 | { 18 | name: 'hydrate', 19 | file: 'index.js', 20 | format: 'es', 21 | globals, 22 | }, 23 | ], 24 | external: ['riot'], 25 | } 26 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { component } from 'riot' 2 | import specialElHandlers from 'morphdom/src/specialElHandlers.js' 3 | 4 | /** 5 | * Create a DOM tree walker 6 | * @param {HTMLElement} node - root node where we will start the crawling 7 | * @returns {TreeWalker} the TreeWalker object 8 | */ 9 | function createWalker(node) { 10 | return document.createTreeWalker( 11 | node, 12 | NodeFilter.SHOW_ELEMENT, 13 | { acceptNode: () => NodeFilter.FILTER_ACCEPT }, 14 | false, 15 | ) 16 | } 17 | 18 | /** 19 | * Sync a source node with the one rendered in runtime 20 | * @param {HTMLElement} sourceNode - node pre-rendered in the DOM 21 | * @param {HTMLElement} targetNode - node generated in runtime 22 | * @returns {undefined} void function 23 | */ 24 | function sync(sourceNode, targetNode) { 25 | const { activeElement } = document 26 | const specialHandler = specialElHandlers[sourceNode.tagName] 27 | 28 | if (sourceNode === activeElement) { 29 | window.requestAnimationFrame(() => { 30 | targetNode.focus() 31 | }) 32 | } 33 | 34 | if (specialHandler) { 35 | specialHandler(targetNode, sourceNode) 36 | } 37 | } 38 | 39 | /** 40 | * Morph the existing DOM node with the new created one 41 | * @param {HTMLElement} sourceElement - the root node already pre-rendered in the DOM 42 | * @param {HTMLElement} targetElement - the root node of the Riot.js component mounted in runtime 43 | * @returns {undefined} void function 44 | */ 45 | function morph(sourceElement, targetElement) { 46 | const sourceWalker = createWalker(sourceElement) 47 | const targetWalker = createWalker(targetElement) 48 | // recursive function to walk source element tree 49 | const walk = (fn) => 50 | sourceWalker.nextNode() && targetWalker.nextNode() && fn() && walk(fn) 51 | 52 | walk(() => { 53 | const { currentNode } = sourceWalker 54 | const targetNode = targetWalker.currentNode 55 | 56 | if (currentNode.tagName === targetNode.tagName) { 57 | sync(currentNode, targetNode) 58 | } 59 | 60 | return true 61 | }) 62 | } 63 | 64 | /** 65 | * Create a custom Riot.js mounting function to hydrate an existing SSR DOM node 66 | * @param {RiotComponentShell} componentAPI - component shell 67 | * @returns {Function} function similar to the riot.component 68 | */ 69 | export default function hydrate(componentAPI) { 70 | const mountComponent = component(componentAPI) 71 | 72 | return (element, props) => { 73 | const clone = element.cloneNode(false) 74 | const instance = mountComponent(clone, props) 75 | 76 | if (instance.onBeforeHydrate) 77 | instance.onBeforeHydrate(instance.props, instance.state) 78 | 79 | // morph the nodes 80 | morph(element, clone) 81 | 82 | // swap the html 83 | element.parentNode.replaceChild(clone, element) 84 | 85 | if (instance.onHydrated) instance.onHydrated(instance.props, instance.state) 86 | 87 | return instance 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /test/components/my-component.riot: -------------------------------------------------------------------------------- 1 | 2 |

{ state.message }

3 | 4 | 5 | 18 |
-------------------------------------------------------------------------------- /test/components/with-loops.riot: -------------------------------------------------------------------------------- 1 | 2 |

With Loops

3 |
4 | {item} 5 |

{item}

6 |
7 | 26 |
-------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | import { expect, use } from 'chai' 2 | import JSDOMGlobal from 'jsdom-global' 3 | import hydrate from '../index.js' 4 | import sinonChai from 'sinon-chai' 5 | import { spy } from 'sinon' 6 | import MyComponent from './components/my-component.riot' 7 | import WithLoops from './components/with-loops.riot' 8 | 9 | describe('@riotjs/hydrate', () => { 10 | before(() => { 11 | JSDOMGlobal(false, { pretendToBeVisual: true }) 12 | use(sinonChai) 13 | }) 14 | 15 | it('it replaces the DOM nodes properly', (done) => { 16 | const root = document.createElement('div') 17 | root.innerHTML = '

goodbye

' 18 | 19 | document.body.appendChild(root) 20 | root.querySelector('input').focus() 21 | 22 | expect(document.activeElement === root.querySelector('input')).to.be.ok 23 | 24 | const instance = hydrate(MyComponent)(root) 25 | 26 | expect(instance.$('p').innerHTML).to.be.equal('hello') 27 | expect(instance.$('input').value).to.be.equal('foo') 28 | 29 | window.requestAnimationFrame(() => { 30 | expect(document.activeElement === instance.$('input')).to.be.ok 31 | done() 32 | }) 33 | }) 34 | 35 | it('it preserves riot DOM events', () => { 36 | const root = document.createElement('div') 37 | root.innerHTML = '

goodbye

' 38 | 39 | document.body.appendChild(root) 40 | const instance = hydrate(MyComponent)(root) 41 | 42 | instance.$('p').click() 43 | 44 | expect(instance.$('p').innerHTML).to.be.equal(instance.state.message) 45 | }) 46 | 47 | it('it triggers the hydrate events', () => { 48 | const root = document.createElement('div') 49 | root.innerHTML = '

goodbye

' 50 | 51 | const beforeSpy = spy() 52 | const afterSpy = spy() 53 | 54 | document.body.appendChild(root) 55 | 56 | hydrate({ 57 | ...MyComponent, 58 | exports: { 59 | ...MyComponent.exports, 60 | onBeforeHydrate: beforeSpy, 61 | onHydrated: afterSpy, 62 | }, 63 | })(root) 64 | 65 | expect(beforeSpy).to.have.been.called 66 | expect(afterSpy).to.have.been.called 67 | }) 68 | 69 | it('it works with loops', () => { 70 | const root = document.createElement('div') 71 | root.innerHTML = '

With Loops

' 72 | 73 | document.body.appendChild(root) 74 | const instance = hydrate(WithLoops)(root) 75 | 76 | instance.insertItems() 77 | expect(instance.$$('p')).to.have.length(5) 78 | 79 | instance.insertNestedItems() 80 | expect(instance.$$('span')).to.have.length(5) 81 | }) 82 | }) 83 | --------------------------------------------------------------------------------