From 509f32ea32b31bce106b9e6f4e85c70290b2caae Mon Sep 17 00:00:00 2001 From: Hadeeb Farhan Date: Fri, 10 Apr 2020 19:30:45 +0530 Subject: [PATCH 1/9] split into smaller components --- src/index.tsx | 12 ++++++ src/onEvents.tsx | 57 +++++++++++++++++++++++++++ src/ssrOnly.tsx | 27 +++++++++++++ src/utils.ts | 96 +++++++++++++++++++++++++++++++++++++++++++++ src/whenIdle.tsx | 56 ++++++++++++++++++++++++++ src/whenVisible.tsx | 71 +++++++++++++++++++++++++++++++++ 6 files changed, 319 insertions(+) create mode 100644 src/onEvents.tsx create mode 100644 src/ssrOnly.tsx create mode 100644 src/utils.ts create mode 100644 src/whenIdle.tsx create mode 100644 src/whenVisible.tsx diff --git a/src/index.tsx b/src/index.tsx index 98dcac3..c8c6c0a 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -2,6 +2,13 @@ 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; whenIdle?: boolean; @@ -59,6 +66,11 @@ const LazyHydrate: React.FunctionComponent = function(props) { } }, []); + React.useEffect(() => { + warnAboutDeprecation({ on, ssrOnly, whenIdle, whenVisible }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + React.useEffect(() => { if (ssrOnly || hydrated) return; const cleanupFns: VoidFunction[] = []; diff --git a/src/onEvents.tsx b/src/onEvents.tsx new file mode 100644 index 0000000..9dd30a4 --- /dev/null +++ b/src/onEvents.tsx @@ -0,0 +1,57 @@ +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]; + + events.forEach(event => { + childRef.current.addEventListener(event, hydrate, { + once: true, + capture: true, + passive: true + }); + cleanupFns.push(() => { + childRef.current.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..3898a5b --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,96 @@ +import * as React from "react"; + +import { isBrowser, isDev } 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 React.useMemo(() => [childRef, hydrated, hydrate], [ + hydrated, + hydrate + ]); +} + +const defaultStyle: React.CSSProperties = { display: "contents" }; + +function warnAboutDeprecation({ on, whenIdle, whenVisible, ssrOnly }) { + if (isDev) { + 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..403299f --- /dev/null +++ b/src/whenIdle.tsx @@ -0,0 +1,56 @@ +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; + + const cleanupFns: VoidFunction[] = []; + + function cleanup() { + for (let i = 0; i < cleanupFns.length; i++) { + cleanupFns[i](); + } + } + + // @ts-ignore + if (requestIdleCallback) { + // @ts-ignore + const idleCallbackId = requestIdleCallback(hydrate, { timeout: 500 }); + cleanupFns.push(() => { + // @ts-ignore + cancelIdleCallback(idleCallbackId); + }); + } else { + const id = setTimeout(hydrate, 2000); + cleanupFns.push(() => { + clearTimeout(id); + }); + } + + return cleanup; + }, [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..1e30e13 --- /dev/null +++ b/src/whenVisible.tsx @@ -0,0 +1,71 @@ +import * as React from "react"; + +import { defaultStyle, useHydrationState } from "./utils"; + +type Props = Omit< + React.HTMLProps, + "dangerouslySetInnerHTML" +> & { + observerOptions?: IntersectionObserverInit; +}; + +function HydrateWhenVisible({ children, observerOptions, ...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](); + } + } + + const io = IntersectionObserver + ? new IntersectionObserver(entries => { + // As only one element is observed, + // there is no need to loop over the array + if (entries.length) { + const entry = entries[0]; + if (entry.isIntersecting || entry.intersectionRatio > 0) { + hydrate(); + } + } + }, observerOptions) + : null; + + if (io && childRef.current.childElementCount) { + // As root node does not have any box model, it cannot intersect. + const el = childRef.current.children[0]; + io.observe(el); + + cleanupFns.push(() => { + io.unobserve(el); + }); + + return cleanup; + } else { + hydrate(); + } + }, [hydrated, hydrate, childRef, observerOptions]); + + if (hydrated) { + return ( +
+ ); + } else { + return ( +
+ ); + } +} + +export { HydrateWhenVisible }; From 7f5b54b0c6222003a5c738cf79c0ad712ce11438 Mon Sep 17 00:00:00 2001 From: Hadeeb Farhan Date: Fri, 10 Apr 2020 20:19:52 +0530 Subject: [PATCH 2/9] remove cleanup function array --- src/whenIdle.tsx | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/src/whenIdle.tsx b/src/whenIdle.tsx index 403299f..ce571d3 100644 --- a/src/whenIdle.tsx +++ b/src/whenIdle.tsx @@ -10,30 +10,20 @@ function HydrateOnIdle({ children, ...rest }: Props) { React.useEffect(() => { if (hydrated) return; - const cleanupFns: VoidFunction[] = []; - - function cleanup() { - for (let i = 0; i < cleanupFns.length; i++) { - cleanupFns[i](); - } - } - // @ts-ignore if (requestIdleCallback) { // @ts-ignore const idleCallbackId = requestIdleCallback(hydrate, { timeout: 500 }); - cleanupFns.push(() => { + return () => { // @ts-ignore cancelIdleCallback(idleCallbackId); - }); + }; } else { const id = setTimeout(hydrate, 2000); - cleanupFns.push(() => { + return () => { clearTimeout(id); - }); + }; } - - return cleanup; }, [hydrated, hydrate]); if (hydrated) { From c276073392ddf82baaa796e2563a86296578111c Mon Sep 17 00:00:00 2001 From: Hadeeb Farhan Date: Sat, 11 Apr 2020 01:47:58 +0530 Subject: [PATCH 3/9] remove newly added hook call --- src/index.tsx | 9 +++----- src/utils.ts | 58 +++++++++++++++++++++++++-------------------------- 2 files changed, 31 insertions(+), 36 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index c8c6c0a..0b5196c 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,7 +1,6 @@ import * as React from "react"; import { isBrowser, isDev } from "./constants.macro"; - import { warnAboutDeprecation } from "./utils"; export * from "./onEvents"; @@ -67,11 +66,9 @@ const LazyHydrate: React.FunctionComponent = function(props) { }, []); React.useEffect(() => { - warnAboutDeprecation({ on, ssrOnly, whenIdle, whenVisible }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - React.useEffect(() => { + if (isDev) { + warnAboutDeprecation({ on, ssrOnly, whenIdle, whenVisible }); + } if (ssrOnly || hydrated) return; const cleanupFns: VoidFunction[] = []; function cleanup() { diff --git a/src/utils.ts b/src/utils.ts index 3898a5b..70da2a9 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,6 +1,6 @@ import * as React from "react"; -import { isBrowser, isDev } from "./constants.macro"; +import { isBrowser } from "./constants.macro"; // React currently throws a warning when using useLayoutEffect on the server. const useIsomorphicLayoutEffect = isBrowser @@ -36,60 +36,58 @@ function useHydrationState(): [ const defaultStyle: React.CSSProperties = { display: "contents" }; function warnAboutDeprecation({ on, whenIdle, whenVisible, ssrOnly }) { - if (isDev) { + console.warn( + "[%creact-lazy-hydration%c]: Default export is deprecated", + "font-weight:bold", + "" + ); + if (on != null) { 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 + `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 + "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 + "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 + "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" - ); - } + "color:red" + ); } } From bcf61cfdff9dcb9cb1b2ab39c3066db77eccbf94 Mon Sep 17 00:00:00 2001 From: Hadeeb Farhan Date: Sun, 12 Apr 2020 15:02:28 +0530 Subject: [PATCH 4/9] whenVisible: remove cleanup functions array --- src/whenVisible.tsx | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/src/whenVisible.tsx b/src/whenVisible.tsx index 1e30e13..035c3c7 100644 --- a/src/whenVisible.tsx +++ b/src/whenVisible.tsx @@ -15,14 +15,6 @@ function HydrateWhenVisible({ children, observerOptions, ...rest }: Props) { React.useEffect(() => { if (hydrated) return; - const cleanupFns: VoidFunction[] = []; - - function cleanup() { - for (let i = 0; i < cleanupFns.length; i++) { - cleanupFns[i](); - } - } - const io = IntersectionObserver ? new IntersectionObserver(entries => { // As only one element is observed, @@ -41,11 +33,9 @@ function HydrateWhenVisible({ children, observerOptions, ...rest }: Props) { const el = childRef.current.children[0]; io.observe(el); - cleanupFns.push(() => { + return () => { io.unobserve(el); - }); - - return cleanup; + }; } else { hydrate(); } From 62b143ea48e08b81c9e65f85ecbcd74c379528f0 Mon Sep 17 00:00:00 2001 From: Hadeeb Farhan Date: Sun, 12 Apr 2020 15:11:58 +0530 Subject: [PATCH 5/9] update types --- src/onEvents.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/onEvents.tsx b/src/onEvents.tsx index 9dd30a4..aa306ab 100644 --- a/src/onEvents.tsx +++ b/src/onEvents.tsx @@ -5,7 +5,7 @@ import { defaultStyle, useHydrationState } from "./utils"; type Props = Omit< React.HTMLProps, "dangerouslySetInnerHTML" -> & { on?: (keyof HTMLElementEventMap)[] | keyof HTMLElementEventMap }; +> & { on: (keyof HTMLElementEventMap)[] | keyof HTMLElementEventMap }; function HydrateOn({ children, on, ...rest }: Props) { const [childRef, hydrated, hydrate] = useHydrationState(); From 03ed575ccf59783d6a5c9445a2d4777902f620d9 Mon Sep 17 00:00:00 2001 From: Hadeeb Farhan Date: Sun, 12 Apr 2020 15:38:09 +0530 Subject: [PATCH 6/9] update types remove a memo hook --- src/utils.ts | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/utils.ts b/src/utils.ts index 70da2a9..20714fd 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -8,7 +8,7 @@ const useIsomorphicLayoutEffect = isBrowser : React.useEffect; function useHydrationState(): [ - React.MutableRefObject, + React.MutableRefObject, boolean, VoidFunction ] { @@ -18,7 +18,7 @@ function useHydrationState(): [ useIsomorphicLayoutEffect(() => { // No SSR Content - if (!childRef.current.hasChildNodes()) { + if (!childRef.current!.hasChildNodes()) { setHydrated(true); } }, []); @@ -27,15 +27,22 @@ function useHydrationState(): [ setHydrated(true); }, []); - return React.useMemo(() => [childRef, hydrated, hydrate], [ - hydrated, - hydrate - ]); + return [childRef, hydrated, hydrate]; } const defaultStyle: React.CSSProperties = { display: "contents" }; -function warnAboutDeprecation({ on, whenIdle, whenVisible, ssrOnly }) { +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", From 8f1d173bd43066a4c9f2c04a050020ae86a1eb80 Mon Sep 17 00:00:00 2001 From: Hadeeb Farhan Date: Sun, 12 Apr 2020 18:14:53 +0530 Subject: [PATCH 7/9] add pure comment --- src/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/index.tsx b/src/index.tsx index 0b5196c..07e33c7 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -24,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) { From 34dec9922f7c210b3b0f58367056fb08be56f2c9 Mon Sep 17 00:00:00 2001 From: Hadeeb Farhan Date: Sun, 12 Apr 2020 18:43:32 +0530 Subject: [PATCH 8/9] read ref value --- src/onEvents.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/onEvents.tsx b/src/onEvents.tsx index aa306ab..6831f30 100644 --- a/src/onEvents.tsx +++ b/src/onEvents.tsx @@ -23,14 +23,16 @@ function HydrateOn({ children, on, ...rest }: Props) { let events = Array.isArray(on) ? on.slice() : [on]; + const domElement = childRef.current!; + events.forEach(event => { - childRef.current.addEventListener(event, hydrate, { + domElement.addEventListener(event, hydrate, { once: true, capture: true, passive: true }); cleanupFns.push(() => { - childRef.current.removeEventListener(event, hydrate, { capture: true }); + domElement.removeEventListener(event, hydrate, { capture: true }); }); }); From 796df8820c7ced65fdc73bd6018da40c32ebf96e Mon Sep 17 00:00:00 2001 From: Hadeeb Farhan Date: Sun, 12 Apr 2020 22:48:37 +0530 Subject: [PATCH 9/9] avoid creating multiple observer instances --- src/whenVisible.tsx | 69 ++++++++++++++++++++++++++++++++++----------- 1 file changed, 52 insertions(+), 17 deletions(-) diff --git a/src/whenVisible.tsx b/src/whenVisible.tsx index 035c3c7..9819fcf 100644 --- a/src/whenVisible.tsx +++ b/src/whenVisible.tsx @@ -9,32 +9,34 @@ type Props = Omit< observerOptions?: IntersectionObserverInit; }; +const hydrationEvent = "hydrate"; + function HydrateWhenVisible({ children, observerOptions, ...rest }: Props) { const [childRef, hydrated, hydrate] = useHydrationState(); React.useEffect(() => { if (hydrated) return; - const io = IntersectionObserver - ? new IntersectionObserver(entries => { - // As only one element is observed, - // there is no need to loop over the array - if (entries.length) { - const entry = entries[0]; - if (entry.isIntersecting || entry.intersectionRatio > 0) { - hydrate(); - } - } - }, observerOptions) - : null; + 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); - if (io && childRef.current.childElementCount) { - // As root node does not have any box model, it cannot intersect. - const el = childRef.current.children[0]; - io.observe(el); + domElement.addEventListener(hydrationEvent, hydrate, { + once: true, + capture: true, + passive: true + }); return () => { - io.unobserve(el); + io.unobserve(domElement); + + domElement.removeEventListener(hydrationEvent, hydrate, { + capture: true + }); }; } else { hydrate(); @@ -58,4 +60,37 @@ function HydrateWhenVisible({ children, observerOptions, ...rest }: Props) { } } +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 };