Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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>')
})
})