I am trying to optimize my react code by fixing any memory leaks. I am using createAsyncThunk canceling-while-running to cancel requests, if my component unmounts.
I have three reducers inside two useEffect hooks. One dispatches when the component mounts and the other two dispatches when the response from the first dispatched reducer arrives.
Below is my code:
useEffect(() => {
const promise = dispatch(bookDetails(bookId))
return () => promise.abort()
}, [])
// this is triggered when the response from the dispatched "bookDetails" reducer arrives
// and get stored in the "book" variable as shown in dependency array
useEffect(() => {
let promise1, promise2
if (book._id !== undefined) {
promise1 = dispatch(getComments(book._id))
promise2 = dispatch(relatedBooks({ genre: book.genre }))
}
return () => {
promise1.abort() // BookDetails.jsx:58:1
promise2.abort()
}
}, [book])
When the component mounts the second useEffect gives me an error, given below:
BookDetails.jsx:58 Uncaught TypeError: Cannot read properties of undefined (reading 'abort')
at BookDetails.jsx:58:1
at safelyCallDestroy (react-dom.development.js:22932:1)
at commitHookEffectListUnmount (react-dom.development.js:23100:1)
at invokePassiveEffectUnmountInDEV (react-dom.development.js:25207:1)
at invokeEffectsInDev (react-dom.development.js:27351:1)
at commitDoubleInvokeEffectsInDEV (react-dom.development.js:27324:1)
at flushPassiveEffectsImpl (react-dom.development.js:27056:1)
at flushPassiveEffects (react-dom.development.js:26984:1)
at performSyncWorkOnRoot (react-dom.development.js:26076:1)
at flushSyncCallbacks (react-dom.development.js:12042:1)
I tried few things but it didn't workout for me.
Since the promise1 and promise2 is assigned within the condition book._id !== undefined , it has the possibility of not being assigned. Simple quick fix is checking a condition when invoking .abort(). Something like promise?.abort() or typeof promise?.abort === 'function' && promise.abort().
There is a condition on dispatching the asynchronous actions in the second useEffect hook. This means that promise1 and promise2 are potentially undefined. Only return the cleanup function if the actions are dispatched.
Example:
useEffect(() => {
if (book._id && book.genre) {
const promise1 = dispatch(getComments(book._id));
const promise2 = dispatch(relatedBooks({ genre: book.genre }));
return () => {
promise1.abort();
promise2.abort();
};
}
}, [book]);
Related
I'm trying to get the coords of the user and once done I'm trying to use "useState" to make that position global but for some reason it's always returning undefined the first time it's ran even though I'm using a promise.
const [globalPosition, setGlobalPosition] = useState<any>(undefined);
useEffect(() => {
const getLocation = new Promise<void>((resolve, reject) => {
navigator.geolocation.getCurrentPosition(success, error, {
enableHighAccuracy: true,
});
function success(position: any) {
setGlobalPosition(position);
resolve();
}
function error(err: any) {
console.log(err);
reject();
}
});
getLocation.then(() => {
console.log(globalPosition);
});
}, []);
The first render will always be undefined because that's what you defined as the initial value.
const [globalPosition, setGlobalPosition] = useState<any>(undefined);
// ^^^^^^^^^
When a state setting function is called, such as inside your useEffect, the component will rerender.
If you don't want to continue rendering while undefined, after that useEffect add one of these options (or your own loading state):
if (globalPosition == undefined) return null;
if (globalPosition == undefined) return <div>Loading...</div>;
Also because of how React state and const work, getLocation.then's globalPosition will not be the new version of the variable after you change it since it's storing a copy/reference to the original state at the beginning of that render.
I have a function that returns an object.
It looks like this
const aggResults = data => {
const { surveyName, questionId } = data
const db = firebase.firestore()
const surveyData = db.collection('surveys').doc(surveyName)
return surveyData.onSnapshot(doc => {
console.log('test', doc.data().aggResults[questionId])
return doc.data().aggResults[questionId]
})
}
Frome the same file, I call my function like this
{
...
output: {
...
function: () => aggResults({ surveyName: '...', questionId: '...' }),
},
},
In a separate file, I want to update my state based with what aggResults returns.
I have used a useEffect hook to do this
const [data, updateData] = useState({})
useEffect(() => {
if (!_.isUndefined(question.output)) {
question.output.function().then(res => updateData(res))
} else {
console.log('q', question.output)
}
}, [question])
The error I'm getting at the moment is TypeError: question.output.function().then is not a function
Where am I going wrong?
onSnapshot doesn't return a promise. It returns an unsubscribe function that you call to remove the listener from the DocumentReference. You can see an example of this in the documentation. When you call question.output.function(), it is going to return you this unsubscribe function.
Snapshot listeners are for receive updates to a document as it changes over time. If you're trying to do a single query without listening for ongoing changes, you should use get() instead of onSnapshot(), as shown in the documentation. get() returns a promise that resolves with a DocumentSnapshot contains the document data.
In my componentDidMount of a React.Component instance I have a fetch() call that on response calls setState.
I can mock out the request and respond using sinon but I don't know when fetch will have resolved it's promise chain.
componentDidMount() {
fetch(new Request('/blah'))
.then((response) => {
setState(() => {
return newState;
};
});
}
In my test using jest with enzyme:
it('has new behaviour from result of set state', () => {
let component = mount(<Component />);
requests.pop().respond(200);
component.update() // fetch() has not responded yet and
// thus setState has not been called yet
// so does nothing
assertNewBehaviour(); // fails
// now setState occurs after fetch() responds sometime after
});
Do I need to flush the Promise queue/callback queue or something similar? I could do a repeated check for newBehaviour with a timeout but that's less than ideal.
The best answer it seems is to be use a container pattern and pass down the API data from a container class with separated concerns and test the components separately. This allows the component under test to simply take the API data as props and makes it much more testable.
Since you're not making any real api calls or other time-consuming operations, the asynchronous operation will resolve in a predictably short time.
You can therefore simply wait a while.
it('has new behaviour from result of set state', (done) => {
let component = mount(<Component />);
requests.pop().respond(200);
setTimeout(() => {
try {
component.update();
assertNewBehaviour();
done();
} catch (error) {
done(error);
}
}, 1000);
});
The react testing library has a waitFor function that works perfectly for this case scenario.
I will give an example with hooks and function as that is the current react pattern. Lets say you have a component similar to this one:
export function TestingComponent(props: Props) {
const [banners, setBanners] = useState<MyType>([]);
React.useEffect(() => {
const response = await get("/...");
setBanners(response.banners);
}, []);
return (
{banners.length > 0 ? <Component> : </NoComponent>}
);
}
Now you can write a test like this to make sure that when banners are set Component is rendered
test("when the banner matches the url it renders", async () => {
const {container} = render(<TestingComponent />);
await waitFor(() => {expect(...).toBe(...)});
});
waitFor will wait for the condition in the function to be met before proceeding. There is a timeout that will fail the test if the condition is not met in X time. Check out the react testing library docs for more info
I am trying to test a React Component which includes a call to an api library and therefore returns a promise.
The api library looks like this: (utils/api.js)
import axios from "axios";
import Q from "q";
export default {
createTrip(trip) {
return Q.when(axios.post("/trips/", trip));
}
}
I have mocked it as follows: (utils/__mocks__/api.js)
export default {
createTrip(trip) {
return new Promise((resolve, reject) => {
let response = {status: 201, data: trip};
resolve(response)
})
}
}
The function I am testing is:
create() {
api.createTrip(this.state.trip).then(response => {
if (response.status === 201) {
this.setState({trip: {}, errors: []});
this.props.onTripCreate(response.data);
} else if (response.status === 400) {
this.setState({errors: response.data})
}
});
}
The test is:
jest.mock('utils/api.js');
test('succesful trip create calls onTripCreate prop', () => {
const trip = {'name': faker.random.word()};
const spy = jest.fn();
const container = shallow(<TripCreateContainer onTripCreate={spy}/>);
container.setState({'trip': trip});
container.instance().create();
expect(spy).toHaveBeenCalledWith(trip);
expect(container.state('trip')).toEqual({});
expect(container.state('errors')).toEqual([]);
});
I believe this should work, yet the result of the test is:
succesful trip create calls onTripCreate prop
expect(jest.fn()).toHaveBeenCalledWith(expected)
Expected mock function to have been called with:
[{"name": "copy"}]
But it was not called.
at Object.test (src/Trips/__tests__/Containers/TripCreateContainer.jsx:74:21)
at new Promise (<anonymous>)
at Promise.resolve.then.el (node_modules/p-map/index.js:46:16)
at <anonymous>
I'm not sure how to fix this test, and would be grateful if anyone could help?
You are close.
then queues a callback for execution. Callbacks execute when the current synchronous code completes and the event loop grabs whatever is queued next.
The test is running to completion and failing before the callback queued by then from within create() has a chance to run.
Give the event loop a chance to cycle so the callback has a chance to execute and that should resolve the issue. That can be done by making your test function asynchronous and awaiting on a resolved promise where you want to pause the test and let any queued callbacks execute:
jest.mock('utils/api.js');
test('succesful trip create calls onTripCreate prop', async () => {
const trip = {'name': faker.random.word()};
const spy = jest.fn();
const container = shallow(<TripCreateContainer onTripCreate={spy}/>);
container.setState({'trip': trip});
container.instance().create();
// Pause the synchronous test here and let any queued callbacks execute
await Promise.resolve();
expect(spy).toHaveBeenCalledWith(trip);
expect(container.state('trip')).toEqual({});
expect(container.state('errors')).toEqual([]);
});
I'm trying to mock out the a service that returns promises so that I can verify it gets called with the correct parameters. The way the service is called varies based on the state and the first call to the service sets the state.
When setting the state in the promise it is not updating unless I wrap the assertion in setTimeout or completely stub out the promise. Is there a way to do this with just a plain promise and an expect?
My component:
class App extends Component {
constructor(props) {
super(props);
this.state = {results: []};
this.service = props.service;
this.load = this.load.bind(this);
}
load() {
if (this.state.results.length === 0) {
this.service.load('state is empty')
.then(result => this.setState({results: result.data}));
} else {
this.service.load('state is nonempty')
.then(result => this.setState({results: result.data}));
}
}
render() {
return (
<div className="App">
<button id="submit" onClick={this.load}/>
</div>
);
}
}
My test:
it('Calls service differently based on results', () => {
const mockLoad = jest.fn((text) => {
return new Promise((resolve, reject) => {
resolve({data: [1, 2]});
});
});
const serviceStub = {load: mockLoad};
let component = mount(<App service={serviceStub}/>);
let button = component.find("#submit");
button.simulate('click');
expect(mockLoad).toBeCalledWith('state is empty');
button.simulate('click');
//this assertion fails as the state has not updated and is still 'state is empty'
expect(mockLoad).toBeCalledWith('state is nonempty');
});
As mentioned, the following works, but I'd rather not wrap the expect if there's a way around it:
setTimeout(() => {
expect(mockLoad).toBeCalledWith('state is nonempty');
done();
}, 50);
I can also change how I mock the function to stub out the promise which will work:
const mockLoad = jest.fn((text) => {
return {
then: function (callback) {
return callback({
data : [1, 2]
})
}
}
});
But I'd like to just return a promise.
React batches setState calls for performance reasons, so at this point
expect(mockLoad).toBeCalledWith('state is nonempty');
the condition
if (this.state.results.length === 0) {
is most likely still true, because data has not yet been added to state.
Your best bets here are
Either use forceUpdate between the first and second click event.
Or split the test into two separate, while extracting common logic outside of the test. Even the it clause will become more descriptive, for instance: it('calls service correctly when state is empty') for the first test, and similar for the second one.
I'd favour the second approach.
setState() does not always immediately update the component. It may batch or defer the update until later.
Read more here.
Using Sinon with Sinon Stub Promise I was able to get this to work. The stub promise library removes the async aspects of the promise, which means that state gets updated in time for the render:
const sinon = require('sinon');
const sinonStubPromise = require('sinon-stub-promise');
sinonStubPromise(sinon);
it('Calls service differently based on results', () => {
const mockLoad = jest.fn((text) => {
return sinon.stub().returnsPromise().resolves({data: [1, 2]})();
});
const serviceStub = {load: mockLoad};
let component = mount(<App service={serviceStub}/>);
let button = component.find("#submit");
button.simulate('click');
expect(mockLoad).toBeCalledWith('state is empty');
button.simulate('click');
expect(mockLoad).toBeCalledWith('state is nonempty');
});
See:
http://sinonjs.org/
https://github.com/substantial/sinon-stub-promise