Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

Avoid state update when component is unmount #55

Merged
merged 3 commits into from
May 4, 2020
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
67 changes: 9 additions & 58 deletions src/ReactTinyLink.tsx
Original file line number Diff line number Diff line change
@@ -1,53 +1,12 @@
import * as React from 'react'
import { Card, ContentWrapper, Header, Content, Footer } from './components/Card'
import Description from './components/Description';
import { getHostname } from './utils'
import { getHostname, noop, defaultData } from './utils'
import { IReactTinyLinkProps } from './ReactTinyLinkTypes'
import ScraperWraper from './rules'
import { ReactTinyLinkType, IReactTinyLinkProps, IReactTinyLinkData } from './ReactTinyLinkTypes'
import CardMedia from './components/CardMedia'
import { useMountFetch } from './useMountFetch';

const useEffectAsync = (effect: () => void, input) => {
React.useEffect(() => {
effect()
}, input)
}

const fetchUrl = (
url: string,
proxyUrl: string,
defaultMedia: string,
setData: (data: IReactTinyLinkData) => void,
setLoading: (loading: boolean) => void,
onError: (error: Error) => void,
onSuccess: (response: IReactTinyLinkData) => void,
) => {
setLoading(true)

const client = fetch(proxyUrl ? `${proxyUrl}/${url}` : url, {
headers: {
'x-requested-with': '',
},
})

ScraperWraper(url, client, defaultMedia ? [defaultMedia] : [])
.then((data: IReactTinyLinkData) => {
setData(data)
onSuccess(data)
setLoading(false)
})
.catch((err: any) => {
onError(err)
setData({
title: url.substring(url.lastIndexOf('/') + 1),
description: url.substring(url.lastIndexOf('/') + 1),
image: defaultMedia ? [defaultMedia] : [],
url: url,
video: defaultMedia ? [defaultMedia] : [],
type: ReactTinyLinkType.TYPE_DEFAULT,
})
setLoading(false)
})
}
export const ScrapperWraper = ScraperWraper
export const ReactTinyLink: React.FC<IReactTinyLinkProps> = ({
cardSize = 'small',
Expand All @@ -62,21 +21,13 @@ export const ReactTinyLink: React.FC<IReactTinyLinkProps> = ({
autoPlay = false,
defaultMedia = '',
loadSecureUrl = false,
onError = () => { },
onSuccess = () => { },
onError = noop, // Permit to keep a constant reference
onSuccess = noop,
}: IReactTinyLinkProps) => {
const [data, setData] = React.useState({
title: null,
description: null,
image: null,
type: null,
video: [],
url: null,
})
const [loading, setLoading] = React.useState(false)
useEffectAsync(() => {
fetchUrl(url, proxyUrl, defaultMedia, setData, setLoading, onError, onSuccess)
}, [url, proxyUrl, defaultMedia])

const defaultMediaArr = defaultMedia ? [defaultMedia] : []
const [data, loading] = useMountFetch(
url, proxyUrl, defaultMediaArr, defaultData(url, defaultMediaArr), onError, onSuccess)

return (
<>
Expand Down
25 changes: 25 additions & 0 deletions src/demo/Hover.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import * as React from 'react'

export interface HoverProps extends React.HTMLProps<HTMLDivElement> {
constant: React.ReactNode
}

export const Hover: React.FC<HoverProps> = ({
constant,
children,
...divProps
}: HoverProps) => {
const [open, setOpen] = React.useState<boolean>(false)

/** toggle `open` value */
const toggleOpen = React.useCallback(() => {
setOpen(old => !old)
}, [])

return (
<div onMouseEnter={toggleOpen} onMouseLeave={toggleOpen} {...divProps}>
{constant}
{open && children}
</div>
)
}
19 changes: 16 additions & 3 deletions src/demo/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as React from 'react'
import { render } from 'react-dom'
import { ReactTinyLink } from '../../lib/index'
import { Hover } from './Hover'
import { ReactTinyLink } from '../../src/ReactTinyLink'

const Demo: React.FC = () => (
<>
Expand Down Expand Up @@ -40,6 +41,16 @@ const Demo: React.FC = () => (
<img alt="Edit React Tiny Link" src="https://codesandbox.io/static/img/play-codesandbox.svg" />
</a>
</p>
<Hover constant={<h3>Resistant to premature unmounts</h3>}>
<ReactTinyLink
cardSize="small"
showGraphic={true}
maxLine={2}
minLine={1}
autoPlay={true}
url="https://ekee.io/"
/>
</Hover>
<h3>Amazon url example</h3>
<ReactTinyLink
cardSize="small"
Expand Down Expand Up @@ -348,6 +359,7 @@ const Demo: React.FC = () => (
/>`}
</code>
</pre>
*/}
<footer>
Made with <i className="fa fa-heart" style={{ color: `red` }} /> in Singapore
<p>
Expand All @@ -366,6 +378,7 @@ const Demo: React.FC = () => (
</p>
</footer>
</>
)
; (window as any).env = process.env.NODE_ENV
);

(window as any).env = process.env.NODE_ENV
render(<Demo />, document.getElementById('demo'))
112 changes: 112 additions & 0 deletions src/useMountFetch.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { useState, useEffect } from 'react'

// Helpers
import ScraperWraper from './rules'
import { makeCancelable } from './utils'

// Types
import { IReactTinyLinkData } from './ReactTinyLinkTypes'

/** current state of the fetch */
export interface IState<T,E> {
/**
* The respones to the request. Undefined if the request not done or returned
* an error.
*/
response: T|undefined

/** Loading status. `true` if loading, `false` otherwise. */
loading: boolean,

/**
* Error status. Undefined if the request returned a valid response or is
* loading.
*/
error: E|undefined
}

/** array composed of:
* 1. The respones to the request. Undefined if the request not done or returned
* an error.
* 2. The loading status. `true` if loading, `false` otherwise.
* 3. Ther error status. Undefined if the request returned a valid response or is
* loading.
*/
export type ResponseState<T,E> = [T|undefined, boolean, E|undefined]

export function useMountFetch(
url: string,
proxyUrl: string,

/** default medias passed to the `ScraperWraper` */
defaultMedias: string[],

/**
* Permits to pass a default value. This will be the response's value
* during the loading and in case of error.
*/
defaultValue?: IReactTinyLinkData,

// Hooks for the caller
/** Called when the fetch failed with the reason of the failure */
onError?: (error: Error) => void,

/** Called when the fetch succeeded with the resulting data */
onSuccess?: (response: IReactTinyLinkData) => void,
): ResponseState<IReactTinyLinkData, Error>
{
// Alias to IState
type State = IState<IReactTinyLinkData, Error>

// Setup the state
const [state, setState] = useState<State>({
response: defaultValue,
loading: true, // Avoid a re-render to set to true
error: undefined
})

/** Does the fetch on mount. Ensure the cleanup in case of premature unmounting */
useEffect(() => {
// Permit to control if should set the state. Avoiding a memory leak
let isMounted: boolean = true

// Wraps the `ScraperWraper` to manage the hook's state
const doFetch = async (): Promise<IReactTinyLinkData> => {
const finalStateUpdate: Partial<State> = {loading: false, error: undefined}

try {
// actual request to preview the link
const client = fetch(proxyUrl ? `${proxyUrl}/${url}` : url, {headers})

const data = await ScraperWraper(url, client, defaultMedias)
finalStateUpdate.response = data

onSuccess && isMounted && onSuccess(data)
return data
} catch (err) {
finalStateUpdate.error = err
onError && isMounted && onError(err)

return err
} finally {
isMounted && setState(old => ({...old, ...finalStateUpdate}))
}
}

// Makes the request and wraps it so we can cancel it if needed
const cancelable = makeCancelable(doFetch())

// Returns a cleanup function which permits to avoid potential
// memory leaks and unnecessary network when the component is
// unmount.
return () => {
isMounted = false // Avoid all the state management
cancelable.cancel() // Cancel the request
}
}, []) // Put no dependecy, does the fetch only once on mount

return [state.response, state.loading, state.error]
}

/** headers passed to the fetch request */
const headers = { 'x-requested-with': '' }
48 changes: 48 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { css } from 'styled-components';
import { IReactTinyLinkData, ReactTinyLinkType } from './ReactTinyLinkTypes';

const REGEX_STRIP_WWW = /^www\./;
export const media = {
Expand Down Expand Up @@ -35,3 +36,50 @@ export const findFirstSecureUrl = (records, condition) => {
const result = records.find(record => condition(record) && record.startsWith('https://'));
return result ? result : '';
}

/**
* @returns {IReactTinyLinkData} with default values.
*
* @param url url to fetch
* @param defaultMedia media assigned to both the image and the video
*/
export function defaultData(url: string, defaultMedia: string[]): IReactTinyLinkData {
return {
title: url.substring(url.lastIndexOf('/') + 1),
description: url.substring(url.lastIndexOf('/') + 1),
image: defaultMedia,
url: url,
video: defaultMedia,
type: ReactTinyLinkType.TYPE_DEFAULT,
}
}

/** does nothing. Just here to have a constance reference */
export function noop() {
return
}

/**
* Wraps a promise to make it cancelable.
*
* from https://reactjs.org/blog/2015/12/16/ismounted-antipattern.html
* defined by @istarkov at:
* https://github.com/facebook/react/issues/5465#issuecomment-157888325
*/
export const makeCancelable = (promise) => {
let hasCanceled_ = false;

const wrappedPromise = new Promise((resolve, reject) => {
promise.then(
val => hasCanceled_ ? reject({isCanceled: true}) : resolve(val),
error => hasCanceled_ ? reject({isCanceled: true}) : reject(error)
);
});

return {
promise: wrappedPromise,
cancel() {
hasCanceled_ = true;
},
};
};