Can't get an example for React useMutationObserver hook to run - reactjs

I need to check the mutation of an react component in my app that I am developing. So I looked around for solutions for that and found this example. To learn how it works I am trying to implement it in my own app: https://www.30secondsofcode.org/react/s/use-mutation-observer
I have created a blank new React app but I can't get it to run like it does in the codepen provided on the site.
1st. ref is missing as a dependency in the useEffect hook, so I added it.
2nd. It does nothing, it updates the text output in <p>{content}</p> but it keeps staying on mutationCount: 0
Here my App.js
import React from "react";
const useMutationObserver = (
ref,
callback,
options = {
attributes: true,
characterData: true,
childList: true,
subtree: true,
}
) => {
React.useEffect(() => {
if (ref.current) {
const observer = new MutationObserver(callback);
observer.observe(ref.current, options);
return () => observer.disconnect();
}
}, [callback, options, ref]); //added ref here
};
const App = () => {
const mutationRef = React.useRef();
const [mutationCount, setMutationCount] = React.useState(0);
const incrementMutationCount = () => {
return setMutationCount(mutationCount + 1);
};
useMutationObserver(mutationRef, incrementMutationCount);
const [content, setContent] = React.useState('Hello world');
return (
<>
<label htmlFor="content-input">Edit this to update the text:</label>
<textarea
id="content-input"
style={{ width: '100%' }}
value={content}
onChange={e => setContent(e.target.value)}
/>
<div
style={{ width: '100%' }}
ref={mutationRef}
>
<div
style={{
resize: 'both',
overflow: 'auto',
maxWidth: '100%',
border: '1px solid black',
}}
>
<h2>Resize or change the content:</h2>
<p>{content}</p>
</div>
</div>
<div>
<h3>Mutation count {mutationCount}</h3>
</div>
</>
);
};
export default App;
What is different in my app?

A new instance of callback and options is created every time App component re-renders, making the useEffect callback function running on every change. You can remove them from the dependency list and make sure that useEffect block will run after component is mounted or if ref changes only:
const useMutationObserver = (
ref,
callback,
options = {
CharacterData: true,
childList: true,
subtree: true,
attributes: true,
}
) => {
React.useEffect(() => {
if (ref.current) {
const observer = new MutationObserver(callback);
observer.observe(ref.current, options);
return () => observer.disconnect();
}
}, [ref]);
};
Working Example
Another solution is to keep callback and options reference with no changes.

Related

In React I get the warning: Function component cannot be given refs. Attempts to access this ref will fail. Did you mean to use React.forwardRef()?

Here's are a few snippets from my code. I have a function component defined as:
function AssociationViewer(props) {
const core = React.useRef<GraphCore | null>(null);
const dataService = React.useRef<DataService>(new DataService(props.graphApi));
const selectionBoxDimensions = React.useRef(null);
const initialGraphRender = React.useRef<boolean>(false);
const filterRef = React.useRef(null);
const getElementRef = React.useCallback((el) => {
if (el && !core.current) {
core.current = new GraphCore({ container: el });
// TODO: Change data service to accept core as an argument and initialize it here?
dataService.current.addGraphDataFn = (opts) => getRksGraph().addData(opts);
dataService.current.setGraphDataFn = (opts) => getRksGraph().setData(opts);
onMount();
return onUnmount;
}
}, []);
.
.
.
return (
<>
{props.enableSearch && <div style={{zIndex: 10000, position: 'absolute', marginTop: 10, right: 15}}>
<button onClick={flashSearchedNodes}>Search</button>
<input
value={searchText}
placeholder='Find node by text'
onKeyDown={(e) => e.key == 'Enter' && flashSearchedNodes()}
onChange={(e) => setSearchText(e.target.value)}
/>
<input readOnly style={{width: '60px', textAlign: 'center'}} type="text" value={searchedElesDisplayText} />
<button onClick={prevFoundNode}>Prev</button>
<button onClick={nextFoundNode}>Next</button>
<button onClick={cancelFlashNodes}>Clear</button>
</div>}
<div
style={{ height: '100%', width: '100%' }}
id={props.componentId || 'kms-graph-core-component'}
ref={getElementRef}
></div>
{core.current && (
<GraphTooltip
tooltip={props.tooltipCallback}
tooltipHoverHideDelay={props.tooltipHoverHideDelay}
tooltipHoverShowDelay={props.tooltipHoverShowDelay}
tippyOptions={props.tippyOptions}
core={core.current}
/>
)}
{props.loadingMask && !hasCustomLoadMask() && (
<DefaultLoadMask
active={showLoading}
loadingClass={getLoadingClass()}
onClick={() => {
setShowLoading(false);
}}
/>
)}
{props.loadingMask && showLoading && hasCustomLoadMask() && props.customLoadingMask()}
</>
);
}
export default AssociationViewer;
I have an angular app that uses a service to call this component as follows:
ReactService.render(AssociationViewer,
{
ref: function (el) {
reactElement = el;
},
component: 'association-viewer-1',
getImageUrl: getImageUrl,
graphApi: graphApi,
pairElements: $ctrl.pairElements,
theme: theme,
view: 'testView'
}
'miniGraphContainer',
() => {
if ($ctrl.pairElements.edges) {
reactElement.core.select($ctls.pairElements.edges($ctrl.currEdge).id);
}
}
Here's GraphTooltip:
import React from 'react';
import GraphCore from '../GraphCore';
function useForceUpdate() {
const [value, setValue] = React.useState(0);
return () => setValue((value) => ++value);
}
interface IGraphTooltipProps{
tooltipHoverShowDelay: number;
tooltipHoverHideDelay: number;
core: GraphCore;
tooltip: Function;
tippyOptions: any;
}
const GraphTooltip = (props: IGraphTooltipProps) => {
const tooltipRef = React.useRef<React.Element>(null);
const forceUpdate = useForceUpdate();
const setRef = (ref) => {
tooltipRef.current = ref;
};
const [currentElement, setCurrentElement] = React.useState<React.Element>();
React.useEffect(() => {
props.core.getAllSubGraphs().forEach((subgraph) => {
subgraph.setOptions({
tooltip: {
domElementCallback: (e) => {
// this isn't changing so it's not picking up a render loop
setCurrentElement(e);
forceUpdate();
return tooltipRef.current;
},
hoverShowDelay: props.tooltipHoverShowDelay,
hoverHideDelay: props.tooltipHoverHideDelay,
options: props.tippyOptions
}
});
});
}, []);
return <>{props.tooltip(setRef, currentElement, props.core)}</>;
};
export default GraphTooltip;
When triggering the event that causes this ReactService to render the AssociationViewer component, I get the warning: Function component cannot be given refs. Attempts to access this ref will fail. Did you mean to use React.forwardRef()? Also, reactElement is undefined since the ref cannot be accessed. How can I use React.forwardRef() in the AssociationViewer component to forward the ref to the calling component?
So, here you just have to wrap your functional component inside React.forwardRef()
<GraphTooltip
tooltip={props.tooltipCallback}
tooltipHoverHideDelay={props.tooltipHoverHideDelay}
tooltipHoverShowDelay={props.tooltipHoverShowDelay}
tippyOptions={props.tippyOptions}
core={core.current} // Due to this you are getting warning
/>
Go to your functional component "GraphTooltip" and wrap your export statement in forwardRef().
export const React.forwardRef(GraphTooltip)
Hope it helps!

How to make data persist on refresh React JS?

I have a code where I mount a table with some firebase data but for some reason the values disappear and I been struggling for the next 2 weeks trying to solve this issue I haven't found a solution to this and I have asked twice already and I have try everything so far but it keeps disappearing.
Important Update
I just want to clarify the following apparently I was wrong the issue wasn't because it was a nested collection as someone mentioned in another question. The issue is because my "user" is getting lost in the process when I refresh.
I bring the user from the login to the app like this:
<Estudiantes user={user} />
and then I receive it as a props
function ListadoPedidos({user})
but is getting lost and because is getting lost when I try to use my firebase as:
estudiantesRef = db.collection("usuarios").doc(user.uid).collection("estudiantes")
since the user is "lost" then the uid will be null. Since is null it will never reach the collection and the docs.
I have a simple solution for you. Simply raise the parsing of localStorage up one level, passing the preloadedState into your component as a prop, and then using that to initialize your state variable.
const ListadoEstudiantes = (props) => {
const estData = JSON.parse(window.localStorage.getItem('estudiantes'));
return <Listado preloadedState={estData} {...props} />;
};
Then initialize state with the prop
const initialState = props.preloadedState || [];
const [estudiantesData, setEstudiantesData] = useState(initialState);
And finally, update the useEffect hook to persist state any time it changes.
useEffect(() => {
window.localStorage.setItem('estudiantes', JSON.stringify(estudiantes));
}, [estudiantes]);
Full Code
import React, { useState, useEffect } from 'react';
import { db } from './firebase';
import { useHistory } from 'react-router-dom';
import './ListadoEstudiantes.css';
import {
DataGrid,
GridToolbarContainer,
GridToolbarFilterButton,
GridToolbarDensitySelector,
} from '#mui/x-data-grid';
import { Button, Container } from '#material-ui/core';
import { IconButton } from '#mui/material';
import PersonAddIcon from '#mui/icons-material/PersonAddSharp';
import ShoppingCartSharpIcon from '#mui/icons-material/ShoppingCartSharp';
import DeleteOutlinedIcon from '#mui/icons-material/DeleteOutlined';
import { Box } from '#mui/system';
const ListadoEstudiantes = (props) => {
const estData = JSON.parse(window.localStorage.getItem('estudiantes'));
return <Listado preloadedState={estData} {...props} />;
};
const Listado = ({ user, preloadedState }) => {
const history = useHistory('');
const crearEstudiante = () => {
history.push('/Crear_Estudiante');
};
const initialState = preloadedState || [];
const [estudiantesData, setEstudiantesData] = useState(initialState);
const parseData = {
pathname: '/Crear_Pedidos',
data: estudiantesData,
};
const realizarPedidos = () => {
if (estudiantesData == 0) {
window.alert('Seleccione al menos un estudiante');
} else {
history.push(parseData);
}
};
function CustomToolbar() {
return (
<GridToolbarContainer>
<GridToolbarFilterButton />
<GridToolbarDensitySelector />
</GridToolbarContainer>
);
}
const [estudiantes, setEstudiantes] = useState([]);
const [selectionModel, setSelectionModel] = useState([]);
const columns = [
{ field: 'id', headerName: 'ID', width: 100 },
{ field: 'nombre', headerName: 'Nombre', width: 200 },
{ field: 'colegio', headerName: 'Colegio', width: 250 },
{ field: 'grado', headerName: 'Grado', width: 150 },
{
field: 'delete',
width: 75,
sortable: false,
disableColumnMenu: true,
renderHeader: () => {
return (
<IconButton
onClick={() => {
const selectedIDs = new Set(selectionModel);
estudiantes
.filter((x) => selectedIDs.has(x.id))
.map((x) => {
db.collection('usuarios')
.doc(user.uid)
.collection('estudiantes')
.doc(x.uid)
.delete();
});
}}
>
<DeleteOutlinedIcon />
</IconButton>
);
},
},
];
const deleteProduct = (estudiante) => {
if (window.confirm('Quiere borrar este estudiante ?')) {
db.collection('usuarios').doc(user.uid).collection('estudiantes').doc(estudiante).delete();
}
};
useEffect(() => {}, [estudiantesData]);
const estudiantesRef = db.collection('usuarios').doc(user.uid).collection('estudiantes');
useEffect(() => {
estudiantesRef.onSnapshot((snapshot) => {
const tempData = [];
snapshot.forEach((doc) => {
const data = doc.data();
tempData.push(data);
});
setEstudiantes(tempData);
console.log(estudiantes);
});
}, []);
useEffect(() => {
window.localStorage.setItem('estudiantes', JSON.stringify(estudiantes));
}, [estudiantes]);
return (
<Container fixed>
<Box mb={5} pt={2} sx={{ textAlign: 'center' }}>
<Button
startIcon={<PersonAddIcon />}
variant="contained"
color="primary"
size="medium"
onClick={crearEstudiante}
>
Crear Estudiantes
</Button>
<Box pl={25} pt={2} mb={2} sx={{ height: '390px', width: '850px', textAlign: 'center' }}>
<DataGrid
rows={estudiantes}
columns={columns}
pageSize={5}
rowsPerPageOptions={[5]}
components={{
Toolbar: CustomToolbar,
}}
checkboxSelection
//Store Data from the row in another variable
onSelectionModelChange={(id) => {
setSelectionModel(id);
const selectedIDs = new Set(id);
const selectedRowData = estudiantes.filter((row) => selectedIDs.has(row.id));
setEstudiantesData(selectedRowData);
}}
{...estudiantes}
/>
</Box>
<Button
startIcon={<ShoppingCartSharpIcon />}
variant="contained"
color="primary"
size="medium"
onClick={realizarPedidos}
>
Crear pedido
</Button>
</Box>
</Container>
);
};
I suspect that it's because this useEffect does not have a dependency array and is bring run on every render.
useEffect (() => {
window.localStorage.setItem("estudiantes", JSON.stringify(estudiantes))
})
Try adding a dependency array as follows:
useEffect (() => {
if (estudiantes && estudiantes.length>0)
window.localStorage.setItem("estudiantes", JSON.stringify(estudiantes))
},[estudiantes])
This will still set the localStorage to [] when it runs on the first render. But when the data is fetched and estudiantes is set, the localStorage value will be updated. So I've added a check to check if it's not the empty array.
Change the dependency array of this useEffect to []:
estudiantesRef.onSnapshot(snapshot => {
const tempData = [];
snapshot.forEach((doc) => {
const data = doc.data();
tempData.push(data);
});
setEstudiantes(tempData);
console.log(estudiantes)
})
}, []);
The data flow in your code is somewhat contradictory, so I modify your code, and it works fine.
You can also try delete or add button, it will modify firebase collection, then update local data.
You can click refresh button in codesandbox previewer (not browser) to observe the status of data update.
Here is the code fargment :
// Set value of `localStorage` to component state if it exist.
useEffect(() => {
const localStorageEstData = window.localStorage.getItem("estudiantes");
localStorageEstData && setEstudiantes(JSON.parse(localStorageEstData));
}, []);
// Sync remote data from firebase to local component data state.
useEffect(() => {
// Subscribe onSnapshot
const unSubscribe = onSnapshot(
collection(db, "usuarios", user.id, "estudiantes"),
(snapshot) => {
const remoteDataSource = snapshot.docs.map((doc) => ({
id: doc.id,
...doc.data()
}));
console.info(remoteDataSource);
setEstudiantes(remoteDataSource);
}
);
return () => {
//unSubscribe when component unmount.
unSubscribe();
};
}, [user.id]);
// when `estudiantes` state update, `localStorage` will update too.
useEffect(() => {
window.localStorage.setItem("estudiantes", JSON.stringify(estudiantes));
}, [estudiantes]);
Here is the full code sample :
Hope to help you :)

GSAP Tween taking longer on each play in React

I am using GSAP's timeline to animate elements and it looks like it's taking longer and longer each time. In the example below, you can click on the box to animate it, and then click to reverse it. You can see in my setup that I don't have any delays set. If you open the console you will see the log takes longer and longer to execute the message in the onComplete function.
From research I've done, it looks like I am somehow adding a Tween, but I can't figure out how to solve this. Any help would be greatly appreciated. CodePen here.
const { useRef, useEffect, useState } = React
// set up timeline
const animTimeline = gsap.timeline({
paused: true,
duration: .5,
onComplete: function() {
console.log('complete');
}
})
const Box = ({ someState, onClick }) => {
const animRef = useRef();
animTimeline.to(animRef.current, {
x: 200,
})
useEffect(() => {
someState ? animTimeline.play() : animTimeline.reverse();
}, [someState])
return (
<div
className="box"
onClick={onClick}
ref={animRef}
>
</div>
)
}
const App = () => {
const [someState, setSomeState] = useState(false);
return(
<Box
someState={someState}
onClick={() => setSomeState(prevSomeState => !prevSomeState)}
/>
);
}
ReactDOM.render(<App />,
document.getElementById("root"))
Issue
I think the issue here is that you've the animTimeline.to() in the component function body so this adds a new tweening to the animation each time the component is rendered.
Timeline .to()
Adds a gsap.to() tween to the end of the timeline (or elsewhere using
the position parameter)
const Box = ({ someState, onClick }) => {
const animRef = useRef();
animTimeline.to(animRef.current, { // <-- adds a new tween each render
x: 200,
})
useEffect(() => {
someState ? animTimeline.play() : animTimeline.reverse();
}, [someState])
return (
<div
className="box"
onClick={onClick}
ref={animRef}
>
</div>
)
}
Solution
Use a mounting effect to add just the single tweening.
const animTimeline = gsap.timeline({
paused: true,
duration: .5,
onComplete: function() {
animTimeline.pause();
console.log('complete');
},
onReverseComplete: function() {
console.log('reverse complete');
}
})
const Box = ( { someState, onClick }) => {
const animRef = useRef();
useEffect(() => {
animTimeline.to(animRef.current, { // <-- add only one
x: 200,
});
}, []);
useEffect(() => {
someState ? animTimeline.play() : animTimeline.reverse();
}, [someState])
return (
<div
className="box"
onClick={onClick}
ref={animRef}
/>
)
};
Demo

eslint warning for missing dependency in useEffect

I am making a searchable Dropdown and getting following eslint warning:
React Hook useEffect has missing dependencies: 'filterDropDown' and 'restoreDropDown'. Either include them or remove the dependency array.
import React, { useState, useEffect, useCallback } from "react";
const SearchableDropDown2 = () => {
const [searchText, setSearchText] = useState("");
const [dropdownOptions, setDropdownOptions] = useState([
"React",
"Angular",
"Vue",
"jQuery",
"Nextjs",
]);
const [copyOfdropdownOptions, setCopyOfDropdownOptions] = useState([
...dropdownOptions,
]);
const [isExpand, setIsExpand] = useState(false);
useEffect(() => {
searchText.length > 0 ? filterDropDown() : restoreDropDown();
}, [searchText]);
const onClickHandler = (e) => {
setSearchText(e.target.dataset.myoptions);
setIsExpand(false);
};
const onSearchDropDown = () => setIsExpand(true);
const closeDropDownHandler = () => setIsExpand(false);
const filterDropDown = useCallback(() => {
const filteredDropdown = dropdownOptions.filter((_) =>
_.toLowerCase().includes(searchText.toLowerCase())
);
setDropdownOptions([...filteredDropdown]);
}, [dropdownOptions]);
const restoreDropDown = () => {
if (dropdownOptions.length !== copyOfdropdownOptions.length) {
setDropdownOptions([...copyOfdropdownOptions]);
}
};
const onSearchHandler = (e) => setSearchText(e.target.value.trim());
return (
<div style={styles.mainContainer}>
<input
type="search"
value={searchText}
onClick={onSearchDropDown}
onChange={onSearchHandler}
style={styles.search}
placeholder="search"
/>
<button disabled={!isExpand} onClick={closeDropDownHandler}>
-
</button>
<div
style={
isExpand
? styles.dropdownContainer
: {
...styles.dropdownContainer,
height: "0vh",
}
}
>
{dropdownOptions.map((_, idx) => (
<span
onClick={onClickHandler}
style={styles.dropdownOptions}
data-myoptions={_}
value={_}
>
{_}
</span>
))}
</div>
</div>
);
};
const styles = {
mainContainer: {
padding: "1vh 1vw",
width: "28vw",
margin: "auto auto",
},
dropdownContainer: {
width: "25vw",
background: "grey",
height: "10vh",
overflow: "scroll",
},
dropdownOptions: {
display: "block",
height: "2vh",
color: "white",
padding: "0.2vh 0.5vw",
cursor: "pointer",
},
search: {
width: "25vw",
},
};
I tried wrapping the filterDropDown in useCallback but then the searchable dropdown stopped working.
Following are the changes incorporating useCallback:
const filterDropDown = useCallback(() => {
const filteredDropdown = dropdownOptions.filter((_) =>
_.toLowerCase().includes(searchText.toLowerCase())
);
setDropdownOptions([...filteredDropdown]);
}, [dropdownOptions, searchText]);
My suggestion would be to not use useEffect at all in the context of what you are achieving.
import React, {useState} from 'react';
const SearchableDropDown = () => {
const [searchText, setSearchText] = useState('');
const dropdownOptions = ['React','Angular','Vue','jQuery','Nextjs'];
const [isExpand, setIsExpand] = useState(false);
const onClickHandler = e => {
setSearchText(e.target.dataset.myoptions);
setIsExpand(false);
}
const onSearchDropDown = () => setIsExpand(true);
const closeDropDownHandler = () => setIsExpand(false);
const onSearchHandler = e => setSearchText(e.target.value.trim());
return (
<div style={styles.mainContainer}>
<input
type="search"
value={searchText}
onClick={onSearchDropDown}
onChange={onSearchHandler}
style={styles.search}
placeholder="search"
/>
<button disabled={!isExpand} onClick={closeDropDownHandler}>
-
</button>
<div
style={
isExpand
? styles.dropdownContainer
: {
...styles.dropdownContainer,
height: '0vh',
}
}
>
{dropdownOptions
.filter(opt => opt.toLowerCase().includes(searchText.toLowerCase()))
.map((option, idx) =>
<span key={idx} onClick={onClickHandler} style={styles.dropdownOptions} data-myoptions={option} value={option}>
{option}
</span>
)}
</div>
</div>
);
};
const styles = {
mainContainer: {
padding: '1vh 1vw',
width: '28vw',
margin: 'auto auto'
},
dropdownContainer: {
width: '25vw',
background: 'grey',
height: '10vh',
overflow: 'scroll'
},
dropdownOptions: {
display: 'block',
height: '2vh',
color: 'white',
padding: '0.2vh 0.5vw',
cursor: 'pointer',
},
search: {
width: '25vw',
},
};
Just filter the dropDownOptions before mapping them to the span elements.
also, if the list of dropDownOptions is ever going to change then use setState else just leave it as a simple list. If dropDownOptions comes from an api call, then use setState within the useEffect hook.
Your current code:
// 1. Filter
const filterDropDown = useCallback(() => {
const filteredDropdown = dropdownOptions.filter((_) =>
_.toLowerCase().includes(searchText.toLowerCase())
);
setDropdownOptions([...filteredDropdown]);
}, [dropdownOptions]);
// 2. Restore
const restoreDropDown = () => {
if (dropdownOptions.length !== copyOfdropdownOptions.length) {
setDropdownOptions([...copyOfdropdownOptions]);
}
};
// 3. searchText change effect
useEffect(() => {
searchText.length > 0 ? filterDropDown() : restoreDropDown();
}, [searchText]);
Above code produces eslint warning that:
React Hook useEffect has missing dependencies: 'filterDropDown' and 'restoreDropDown'. Either include them or remove the dependency array
BUT after doing what the warning says, our code finally looks like:
// 1. Filter
const filterDropDown = useCallback(() => {
const filteredDropdown = dropdownOptions.filter((_) =>
_.toLowerCase().includes(searchText.toLowerCase())
);
setDropdownOptions(filteredDropdown);
}, [dropdownOptions, searchText]);
// 2. Restore
const restoreDropDown = useCallback(() => {
if (dropdownOptions.length !== copyOfdropdownOptions.length) {
setDropdownOptions([...copyOfdropdownOptions]);
}
}, [copyOfdropdownOptions, dropdownOptions.length]);
// 3. searchText change effect
useEffect(() => {
searchText.length > 0 ? filterDropDown() : restoreDropDown();
}, [filterDropDown, restoreDropDown, searchText]);
But the above code has formed an infinite loop because:
"Filter" function is OK. (It will be recreated when searchText or dropdownOptions change. And that makes sense.)
"Restore" function is OK. (It will be recreated when copyOfdropdownOptions or dropdownOptions.length change. And this too makes sense.)
searchText change effect looks bad. This effect will run whenever:
=> (a). searchText is changed (OK), or
=> (b). "Filter" function is changed (Not OK because this function itself will change when this hook is run. Point 1), or
=> (c). "Restore" function is changed (Not OK because this function itself will change when this hook is run. Point 2)
We can clearly see an infinite loop in Point 3 (a, b, c).
How to fix it?
There can be few ways. One could be:
The below copy is constant, so either keep it in a Ref or move it outside the component definition:
const copyOfdropdownOptions = useRef([...dropdownOptions]);
And, move the "Filter" and "Restore" functions inside the hook (i.e. no need to define it outside and put it as a dependency), like so:
useEffect(() => {
if (searchText.length) {
// 1. Filter
setDropdownOptions((prev) =>
prev.filter((_) => _.toLowerCase().includes(searchText.toLowerCase()))
);
} else {
// 2. Restore
setDropdownOptions([...copyOfdropdownOptions.current]);
}
}, [searchText]);
As you can see the above effect will run only when searchText is changed.

How to test React component that uses React Hooks useHistory hook with Enzyme?

I am trying to test a React component using Enzyme. Tests worked fine until we converted the component to hooks. Now I am getting the error, "Error: Uncaught [TypeError: Cannot read property 'history' of undefined]"
I have already read through the following similar issues and wasn't able to solve it:
React Jest/Enzyme Testing: useHistory Hook Breaks Test
testing react component with enzyme
Jest & Hooks : TypeError: Cannot read property 'Symbol(Symbol.iterator)' of undefined
Also this article:
* https://medium.com/7shifts-engineering-blog/testing-usecontext-react-hook-with-enzyme-shallow-da062140fc83
Full component, AccessBarWithRouter.jsx:
/**
* #description Accessibility bar component to allow user to jump focus to different components on screen.
* One dropdown will focus to elements on screen.
* The other will route you to routes in your navigation bar.
*
*/
import React, { useState, useEffect, useRef } from 'react';
import Dropdown from 'react-dropdown-aria';
import { useHistory } from 'react-router-dom';
const AccessBarWithRouter = () => {
const pathname = useHistory().location.pathname;
const [sectionInfo, setSectionInfo] = useState(null);
const [navInfo, setNavInfo] = useState(null);
const [isHidden, setIsHidden] = useState(true);
// creating the refs to change focus
const sectionRef = useRef(null);
const accessBarRef = useRef(null);
// sets focus on the current page from the 1st dropdown
const setFocus = e => {
const currentLabel = sectionInfo[e];
const currentElement = document.querySelector(`[aria-labelledBy='${currentLabel}']`);
currentElement.tabIndex = -1;
sectionRef.current = currentElement;
// can put a .click() after focus to focus with the enter button
// works, but gives error
sectionRef.current.focus();
};
// Changes the page when selecting a link from the 2nd dropdown
const changeView = e => {
const currentPath = navInfo[e];
const accessLinks = document.querySelectorAll('.accessNavLink');
accessLinks.forEach(el => {
if (el.pathname === currentPath) {
el.click();
};
});
};
// event handler to toggle visibility of AccessBar and set focus to it
const accessBarHandlerKeyDown = e => {
if (e.altKey && e.keyCode === 191) {
if (isHidden) {
setIsHidden(false)
accessBarRef.current.focus();
} else setIsHidden(true);
}
}
/**
*
* useEffect hook to add and remove the event handler when 'alt' + '/' are pressed
* prior to this, multiple event handlers were being added on each button press
* */
useEffect(() => {
document.addEventListener('keydown', accessBarHandlerKeyDown);
const navNodes = document.querySelectorAll('.accessNavLink');
const navValues = {};
navNodes.forEach(el => {
navValues[el.text] = el.pathname;
});
setNavInfo(navValues);
return () => document.removeEventListener('keydown', accessBarHandlerKeyDown);
}, [isHidden]);
/**
* #todo figure out how to change the dropdown current value after click
*/
useEffect(() => {
// selects all nodes with the aria attribute aria-labelledby
setTimeout(() => {
const ariaNodes = document.querySelectorAll('[aria-labelledby]');
let sectionValues = {};
ariaNodes.forEach(node => {
sectionValues[node.getAttribute('aria-labelledby')] = node.getAttribute('aria-labelledby');
});
setSectionInfo(sectionValues);
}, 500);
}, [pathname]);
// render hidden h1 based on isHidden
if (isHidden) return <h1 id='hiddenH1' style={hiddenH1Styles}>To enter navigation assistant, press alt + /.</h1>;
// function to create dropDownKeys and navKeys
const createDropDownValues = dropDownObj => {
const dropdownKeys = Object.keys(dropDownObj);
const options = [];
for (let i = 0; i < dropdownKeys.length; i++) {
options.push({ value: dropdownKeys[i]});
}
return options;
};
const sectionDropDown = createDropDownValues(sectionInfo);
const navInfoDropDown = createDropDownValues(navInfo);
return (
<div className ='ally-nav-area' style={ barStyle }>
<div className = 'dropdown' style={ dropDownStyle }>
<label htmlFor='component-dropdown' tabIndex='-1' ref={accessBarRef} > Jump to section: </label>
<div id='component-dropdown' >
<Dropdown
options={ sectionDropDown }
style={ activeComponentDDStyle }
placeholder='Sections of this page'
ariaLabel='Navigation Assistant'
setSelected={setFocus}
/>
</div>
</div>
<div className = 'dropdown' style={ dropDownStyle }>
<label htmlFor='page-dropdown'> Jump to page: </label>
<div id='page-dropdown' >
<Dropdown
options={ navInfoDropDown }
style={ activeComponentDDStyle }
placeholder='Other pages on this site'
ariaLabel='Navigation Assistant'
setSelected={ changeView }
/>
</div>
</div>
</div>
);
};
/** Style for entire AccessBar */
const barStyle = {
display: 'flex',
paddingTop: '.1em',
paddingBottom: '.1em',
paddingLeft: '5em',
alignItems: 'center',
justifyContent: 'flex-start',
zIndex: '100',
position: 'sticky',
fontSize: '.8em',
backgroundColor: 'gray',
fontFamily: 'Roboto',
color: 'white'
};
const dropDownStyle = {
display: 'flex',
alignItems: 'center',
marginLeft: '1em',
};
/** Style for Dropdown component **/
const activeComponentDDStyle = {
DropdownButton: base => ({
...base,
margin: '5px',
border: '1px solid',
fontSize: '.5em',
}),
OptionContainer: base => ({
...base,
margin: '5px',
fontSize: '.5em',
}),
};
/** Style for hiddenH1 */
const hiddenH1Styles = {
display: 'block',
overflow: 'hidden',
textIndent: '100%',
whiteSpace: 'nowrap',
fontSize: '0.01px',
};
export default AccessBarWithRouter;
Here is my test, AccessBarWithRouter.unit.test.js:
import React from 'react';
import Enzyme, { mount } from 'enzyme';
import AccessBarWithRouter from '../src/AccessBarWithRouter.jsx';
import Adapter from 'enzyme-adapter-react-16';
Enzyme.configure({ adapter: new Adapter() });
describe('AccessBarWithRouter component', () => {
it('renders hidden h1 upon initial page load (this.state.isHidden = true)', () => {
const location = { pathname: '/' };
const wrapper = mount(
<AccessBarWithRouter location={location}/>
);
// if AccessBarWithRouter is hidden it should only render our invisible h1
expect(wrapper.exists('#hiddenH1')).toEqual(true);
})
it('renders full AccessBarWithRouter when this.state.isHidden is false', () => {
// set dummy location within test to avoid location.pathname is undefined error
const location = { pathname: '/' };
const wrapper = mount(
<AccessBarWithRouter location={location} />
);
wrapper.setState({ isHidden: false }, () => {
// If AccessBar is not hidden the outermost div of the visible bar should be there
// Test within setState waits for state change before running test
expect(wrapper.exists('.ally-nav-area')).toEqual(true);
});
});
});
I am new to React Hooks so trying to wrap my mind around it. My understanding is that I have to provide some sort of mock history value for my test. I tried creating a separate useContext file as so and wrapping it around my component in the test, but that didn't work:
import React, { useContext } from 'react';
export const useAccessBarWithRouterContext = () => useContext(AccessBarWithRouterContext);
const defaultValues = { history: '/' };
const AccessBarWithRouterContext = React.createContext(defaultValues);
export default useAccessBarWithRouterContext;
My current versions of my devDependencies:
"#babel/cli": "^7.8.4",
"#babel/core": "^7.8.6",
"#babel/polyfill": "^7.0.0-beta.51",
"#babel/preset-env": "^7.8.6",
"#babel/preset-react": "^7.8.3",
"babel-core": "^7.0.0-bridge.0",
"babel-jest": "^25.1.0",
"enzyme": "^3.3.0",
"enzyme-adapter-react-16": "^1.1.1",
"jest": "^25.1.0",
"react": "^16.13.0",
"react-dom": "^16.13.0"
I'm not finding much documentation for testing a component utilizing the useHistory hook in general. It seems Enzyme only started working with React Hooks a year ago, and only for mock, not for shallow rendering.
Anyone have any idea how I can go about this?
The problem here comes from inside of useHistory hook as you can imagine. The hook is designed to be used in consumers of a router provider. If you know the structure of Providers and Consumers, it'll make perfect sense to you that, here the consumer (useHistory) is trying to access some information from provider, which doesn't exist in your text case.
There are two possible solutions:
Wrap your test case with a router
it('renders hidden h1 upon initial page load (this.state.isHidden = true)', () => {
const location = { pathname: '/' };
const wrapper = mount(
<Router>
<AccessBarWithRouter location={location}/>
</Router>
)
});
Mock useHistory hook with a fake history data
jest.mock('react-router-dom', () => {
const actual = require.requireActual('react-router-dom')
return {
...actual,
useHistory: () => ({ methods }),
}
})
I personally prefer the 2nd one as you can place it in setupTests file and forget about it.
If you need to mock it or spy on it, you can overwrite the mock from setupTests file in your specific unit test file.

Resources