diff --git a/src/index.tsx b/src/index.tsx index 98dcac3..07e33c7 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,6 +1,12 @@ import * as React from "react"; import { isBrowser, isDev } from "./constants.macro"; +import { warnAboutDeprecation } from "./utils"; + +export * from "./onEvents"; +export * from "./ssrOnly"; +export * from "./whenIdle"; +export * from "./whenVisible"; export type LazyProps = { ssrOnly?: boolean; @@ -18,7 +24,7 @@ const event = "hydrate"; const io = isBrowser && IntersectionObserver - ? new IntersectionObserver( + ? /*#__PURE__*/ new IntersectionObserver( entries => { entries.forEach(entry => { if (entry.isIntersecting || entry.intersectionRatio > 0) { @@ -60,6 +66,9 @@ const LazyHydrate: React.FunctionComponent = function(props) { }, []); React.useEffect(() => { + if (isDev) { + warnAboutDeprecation({ on, ssrOnly, whenIdle, whenVisible }); + } if (ssrOnly || hydrated) return; const cleanupFns: VoidFunction[] = []; function cleanup() { diff --git a/src/onEvents.tsx b/src/onEvents.tsx new file mode 100644 index 0000000..6831f30 --- /dev/null +++ b/src/onEvents.tsx @@ -0,0 +1,59 @@ +import * as React from "react"; + +import { defaultStyle, useHydrationState } from "./utils"; + +type Props = Omit< + React.HTMLProps, + "dangerouslySetInnerHTML" +> & { on: (keyof HTMLElementEventMap)[] | keyof HTMLElementEventMap }; + +function HydrateOn({ children, on, ...rest }: Props) { + const [childRef, hydrated, hydrate] = useHydrationState(); + + React.useEffect(() => { + if (hydrated) return; + + const cleanupFns: VoidFunction[] = []; + + function cleanup() { + for (let i = 0; i < cleanupFns.length; i++) { + cleanupFns[i](); + } + } + + let events = Array.isArray(on) ? on.slice() : [on]; + + const domElement = childRef.current!; + + events.forEach(event => { + domElement.addEventListener(event, hydrate, { + once: true, + capture: true, + passive: true + }); + cleanupFns.push(() => { + domElement.removeEventListener(event, hydrate, { capture: true }); + }); + }); + + return cleanup; + }, [hydrated, hydrate, on, childRef]); + + if (hydrated) { + return ( +
+ ); + } else { + return ( +
+ ); + } +} + +export { HydrateOn }; diff --git a/src/ssrOnly.tsx b/src/ssrOnly.tsx new file mode 100644 index 0000000..116813b --- /dev/null +++ b/src/ssrOnly.tsx @@ -0,0 +1,27 @@ +import * as React from "react"; + +import { defaultStyle, useHydrationState } from "./utils"; + +type Props = Omit, "dangerouslySetInnerHTML">; + +function SsrOnly({ children, ...rest }: Props) { + const [childRef, hydrated] = useHydrationState(); + + if (hydrated) { + return ( +
+ ); + } else { + return ( +
+ ); + } +} + +export { SsrOnly }; diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..20714fd --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,101 @@ +import * as React from "react"; + +import { isBrowser } from "./constants.macro"; + +// React currently throws a warning when using useLayoutEffect on the server. +const useIsomorphicLayoutEffect = isBrowser + ? React.useLayoutEffect + : React.useEffect; + +function useHydrationState(): [ + React.MutableRefObject, + boolean, + VoidFunction +] { + const childRef = React.useRef(null); + + const [hydrated, setHydrated] = React.useState(!isBrowser); + + useIsomorphicLayoutEffect(() => { + // No SSR Content + if (!childRef.current!.hasChildNodes()) { + setHydrated(true); + } + }, []); + + const hydrate = React.useCallback(() => { + setHydrated(true); + }, []); + + return [childRef, hydrated, hydrate]; +} + +const defaultStyle: React.CSSProperties = { display: "contents" }; + +function warnAboutDeprecation({ + on, + whenIdle, + whenVisible, + ssrOnly +}: { + on?: string | string[]; + whenIdle?: boolean; + whenVisible?: boolean; + ssrOnly?: boolean; +}) { + console.warn( + "[%creact-lazy-hydration%c]: Default export is deprecated", + "font-weight:bold", + "" + ); + if (on != null) { + console.warn( + `To hydrate on events, use the new HydrateOn component + %cimport { HydrateOn } from "react-lazy-hydration"; + + + {children} + + `, + "color:red" + ); + } + if (whenIdle != null) { + console.warn( + `To hydrate on idle, use the new HydrateOnIdle component + %cimport { HydrateOnIdle } from "react-lazy-hydration"; + + + {children} + + `, + "color:red" + ); + } + if (whenVisible != null) { + console.warn( + `To hydrate when component becomes visible, use the new HydrateWhenVisible component + %cimport { HydrateWhenVisible } from "react-lazy-hydration"; + + + {children} + + `, + "color:red" + ); + } + if (ssrOnly != null) { + console.warn( + `To skip client side hydration, use the new SsrOnly component + %cimport { SsrOnly } from "react-lazy-hydration"; + + + {children} + + `, + "color:red" + ); + } +} + +export { useHydrationState, defaultStyle, warnAboutDeprecation }; diff --git a/src/whenIdle.tsx b/src/whenIdle.tsx new file mode 100644 index 0000000..ce571d3 --- /dev/null +++ b/src/whenIdle.tsx @@ -0,0 +1,46 @@ +import * as React from "react"; + +import { defaultStyle, useHydrationState } from "./utils"; + +type Props = Omit, "dangerouslySetInnerHTML">; + +function HydrateOnIdle({ children, ...rest }: Props) { + const [childRef, hydrated, hydrate] = useHydrationState(); + + React.useEffect(() => { + if (hydrated) return; + + // @ts-ignore + if (requestIdleCallback) { + // @ts-ignore + const idleCallbackId = requestIdleCallback(hydrate, { timeout: 500 }); + return () => { + // @ts-ignore + cancelIdleCallback(idleCallbackId); + }; + } else { + const id = setTimeout(hydrate, 2000); + return () => { + clearTimeout(id); + }; + } + }, [hydrated, hydrate]); + + if (hydrated) { + return ( +
+ ); + } else { + return ( +
+ ); + } +} + +export { HydrateOnIdle }; diff --git a/src/whenVisible.tsx b/src/whenVisible.tsx new file mode 100644 index 0000000..9819fcf --- /dev/null +++ b/src/whenVisible.tsx @@ -0,0 +1,96 @@ +import * as React from "react"; + +import { defaultStyle, useHydrationState } from "./utils"; + +type Props = Omit< + React.HTMLProps, + "dangerouslySetInnerHTML" +> & { + observerOptions?: IntersectionObserverInit; +}; + +const hydrationEvent = "hydrate"; + +function HydrateWhenVisible({ children, observerOptions, ...rest }: Props) { + const [childRef, hydrated, hydrate] = useHydrationState(); + + React.useEffect(() => { + if (hydrated) return; + + const io = createIntersectionObserver(observerOptions); + + // As root node does not have any box model, it cannot intersect. + const domElement = childRef.current!.firstElementChild; + + if (io && domElement) { + io.observe(domElement); + + domElement.addEventListener(hydrationEvent, hydrate, { + once: true, + capture: true, + passive: true + }); + + return () => { + io.unobserve(domElement); + + domElement.removeEventListener(hydrationEvent, hydrate, { + capture: true + }); + }; + } else { + hydrate(); + } + }, [hydrated, hydrate, childRef, observerOptions]); + + if (hydrated) { + return ( +
+ ); + } else { + return ( +
+ ); + } +} + +const observerCache = new WeakMap< + IntersectionObserverInit, + IntersectionObserver +>(); + +const defaultOptions = {}; + +function createIntersectionObserver( + observerOptions?: IntersectionObserverInit +) { + if (!IntersectionObserver) return null; + + observerOptions = observerOptions || defaultOptions; + + let io = observerCache.get(observerOptions); + + if (!io) { + observerCache.set( + observerOptions, + (io = new IntersectionObserver(entries => { + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + if (entry.isIntersecting || entry.intersectionRatio > 0) { + entry.target.dispatchEvent(new CustomEvent(hydrationEvent)); + } + } + }, observerOptions)) + ); + } + + return io; +} + +export { HydrateWhenVisible };