Skip to content

Commit

Permalink
Added lazy hydrate component (#55)
Browse files Browse the repository at this point in the history
* Added lazy hydrate component

* Update LazyHydrate.js

* Added comments and docs

* Add hooks deps and docs for hydrated

* Added tests for lazy hydration

* Clean up test

* Added test for clearing style registries

* Left in .only in test

* Refactored how hydrated state is calculated
  • Loading branch information
dijs authored May 13, 2020
1 parent bf950f5 commit bb9e82c
Show file tree
Hide file tree
Showing 2 changed files with 248 additions and 0 deletions.
164 changes: 164 additions & 0 deletions src/LazyHydrate.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import React, { useEffect, useState, useRef } from 'react'
import PropTypes from 'prop-types'

import { StylesProvider, createGenerateClassName } from '@material-ui/core/styles'
import { SheetsRegistry } from 'jss'

let registries = []
const generateClassName = createGenerateClassName()

export function clearLazyHydrateRegistries() {
registries = []
}

/*
This component renders the server side rendered stylesheets for the
lazy hydrated components. Once they become hydrated, these stylesheets
will be removed.
*/
export function LazyStyleElements() {
return (
<>
{registries.map((registry, index) => {
const id = `jss-lazy-${index}`
return <style key={id} id={id} dangerouslySetInnerHTML={{ __html: registry.toString() }} />
})}
</>
)
}

function LazyStylesProvider({ children }) {
const registry = new SheetsRegistry()
registries.push(registry)
return (
<StylesProvider
sheetsManager={new Map()}
serverGenerateClassName={generateClassName}
sheetsRegistry={registry}
>
{children}
</StylesProvider>
)
}

const isBrowser = () => {
if (process.env.NODE_ENV === 'test') {
return process.env.IS_BROWSER === 'true'
}
return (
typeof window !== 'undefined' &&
typeof window.document !== 'undefined' &&
typeof window.document.createElement !== 'undefined'
)
}

// Used for detecting when the wrapped component becomes visible
const io =
isBrowser() && IntersectionObserver
? new IntersectionObserver(entries => {
entries.forEach(entry => {
if (entry.isIntersecting || entry.intersectionRatio > 0) {
entry.target.dispatchEvent(new CustomEvent('visible'))
}
})
})
: null

function LazyHydrateInstance({ className, ssrOnly, children, on, index, ...props }) {
function isHydrated() {
if (isBrowser()) {
if (ssrOnly) return false
return props.hydrated
} else {
return true
}
}

const childRef = useRef(null)
const [hydrated, setHydrated] = useState(isHydrated())

useEffect(() => {
setHydrated(isHydrated())
}, [props.hydrated, ssrOnly])

useEffect(() => {
if (hydrated) return

function hydrate() {
setHydrated(true)
// Remove the server side generated stylesheet
const stylesheet = window.document.getElementById(`jss-lazy-${index}`)
if (stylesheet) {
stylesheet.remove()
}
}

let el
if (on === 'visible') {
if (io && childRef.current.childElementCount) {
// As root node does not have any box model, it cannot intersect.
el = childRef.current.children[0]
io.observe(el)
}
}

childRef.current.addEventListener(on, hydrate, {
once: true,
capture: true,
passive: true,
})

return () => {
if (el) io.unobserve(el)
childRef.current.removeEventListener(on, hydrate)
}
}, [hydrated, on])

if (hydrated) {
return (
<div ref={childRef} style={{ display: 'contents' }} className={className}>
{children}
</div>
)
} else {
return (
<div
ref={childRef}
className={className}
style={{ display: 'contents' }}
suppressHydrationWarning
dangerouslySetInnerHTML={{ __html: '' }}
/>
)
}
}

/**
* LazyHydrate
*
* Example:
*
* <LazyHydrate on="visible">
* <div>some expensive component</div>
* </LazyHydrate>
*
*/

function LazyHydrate({ children, ...props }) {
return (
<LazyHydrateInstance {...props} index={registries.length}>
<LazyStylesProvider>{children}</LazyStylesProvider>
</LazyHydrateInstance>
)
}

LazyHydrate.propTypes = {
// Control the hydration of the component externally with this prop
hydrated: PropTypes.bool,
// Force component to never hydrate
ssrOnly: PropTypes.bool,
// Event to trigger hydration
on: PropTypes.oneOf(['visible', 'click']),
}

export default LazyHydrate
84 changes: 84 additions & 0 deletions test/LazyHydrate.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import React from 'react'
import { mount } from 'enzyme'
import LazyHydrate, {
clearLazyHydrateRegistries,
LazyStyleElements,
} from 'react-storefront/LazyHydrate'

describe('LazyHydrate', () => {
let wrapper

afterEach(() => {
wrapper.unmount()
})

it('should clear registries', () => {
const hydrate = mount(
<LazyHydrate>
<button>click</button>
</LazyHydrate>,
)
wrapper = mount(
<div>
<LazyStyleElements />
</div>,
)
// Should render registered lazy styles
expect(wrapper.find('style').length).toBe(1)
clearLazyHydrateRegistries()
// Simulating next page render
wrapper = mount(
<div>
<LazyStyleElements />
</div>,
)
// Should not hold on to old registered styles
expect(wrapper.find('style').length).toBe(0)
hydrate.unmount()
})

it('should pass event through when hydrated', () => {
const click = jest.fn()
wrapper = mount(
<LazyHydrate hydrated>
<button onClick={click}>click</button>
</LazyHydrate>,
)
wrapper.find('button').simulate('click')
expect(click).toHaveBeenCalled()
})

it('should render children during SSR only mode', () => {
const click = jest.fn()
process.env.IS_BROWSER = 'false'
wrapper = mount(
<LazyHydrate ssrOnly>
<button onClick={click}>click</button>
</LazyHydrate>,
)
expect(wrapper.html()).toContain('<button>click</button>')
})

it('should not render children in the browser during SSR only mode', () => {
const click = jest.fn()
process.env.IS_BROWSER = 'true'
wrapper = mount(
<LazyHydrate ssrOnly>
<button onClick={click}>click</button>
</LazyHydrate>,
)
expect(wrapper.find('button').length).toBe(0)
})

it('should hydrate in browser once triggered', () => {
process.env.IS_BROWSER = 'true'
wrapper = mount(
<LazyHydrate hydrated={false}>
<button>click</button>
</LazyHydrate>,
)
expect(wrapper.html()).not.toContain('<button>click</button>')
wrapper.setProps({ hydrated: true })
expect(wrapper.html()).toContain('<button>click</button>')
})
})

0 comments on commit bb9e82c

Please sign in to comment.