├── .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 `getBoundingClientRect` 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 |
--------------------------------------------------------------------------------