-
Notifications
You must be signed in to change notification settings - Fork 194
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* 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
Showing
2 changed files
with
248 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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>') | ||
}) | ||
}) |