Skip to content

Commit 246208c

Browse files
committed
fix: cache queries for not-mounted yet components. It fixes problems with infinite loops after error occurred.
Closes #23
1 parent d21a946 commit 246208c

File tree

7 files changed

+160
-58
lines changed

7 files changed

+160
-58
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
"react": "^16.7.0-alpha.0"
5656
},
5757
"dependencies": {
58+
"lodash": "^4.17.11",
5859
"react-fast-compare": "^2.0.2",
5960
"warning": "^4.0.2"
6061
},

src/__tests__/useMutation-test.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ it('should create a function to perform mutations', async () => {
159159
);
160160
}
161161

162-
const client = createClient(TASKS_MOCKS);
162+
const client = createClient({ mocks: TASKS_MOCKS });
163163
const { container } = render(
164164
<ApolloProvider client={client}>
165165
<Suspense fallback={<div>Loading</div>}>
@@ -215,7 +215,7 @@ it('should allow to pass options forwarded to the mutation', async () => {
215215
);
216216
}
217217

218-
const client = createClient(TASKS_MOCKS);
218+
const client = createClient({ mocks: TASKS_MOCKS });
219219
const { container, getByTestId } = render(
220220
<ApolloProvider client={client}>
221221
<Suspense fallback={<div>Loading</div>}>

src/__tests__/useQuery-test.js

Lines changed: 92 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
1+
import { ApolloLink, Observable } from 'apollo-link';
12
import gql from 'graphql-tag';
23
import React, { Suspense } from 'react';
3-
import {
4-
cleanup,
5-
flushEffects,
6-
render,
7-
} from 'react-testing-library';
4+
import { cleanup, flushEffects, render } from 'react-testing-library';
85

96
import { ApolloProvider, useQuery } from '..';
107
import createClient from '../__testutils__/createClient';
@@ -123,13 +120,17 @@ function TasksLoader({ query, ...restOptions }) {
123120
}
124121

125122
function TasksLoaderWithoutSuspense({ query, ...restOptions }) {
126-
const { data, error, loading } = useQuery(query, {
123+
const { data, error, errors, loading } = useQuery(query, {
127124
...restOptions,
128125
suspend: false,
129126
});
127+
130128
if (error) {
131129
throw error;
132130
}
131+
if (errors) {
132+
throw new Error('Errors');
133+
}
133134
if (loading) {
134135
return 'Loading without suspense';
135136
}
@@ -139,7 +140,7 @@ function TasksLoaderWithoutSuspense({ query, ...restOptions }) {
139140
afterEach(cleanup);
140141

141142
it('should return the query data', async () => {
142-
const client = createClient(TASKS_MOCKS);
143+
const client = createClient({ mocks: TASKS_MOCKS });
143144
const { container } = render(
144145
<ApolloProvider client={client}>
145146
<Suspense fallback={<div>Loading</div>}>
@@ -157,7 +158,7 @@ it('should return the query data', async () => {
157158
});
158159

159160
it('should work with suspense disabled', async () => {
160-
const client = createClient(TASKS_MOCKS);
161+
const client = createClient({ mocks: TASKS_MOCKS });
161162
const { container } = render(
162163
<ApolloProvider client={client}>
163164
<TasksLoaderWithoutSuspense query={TASKS_QUERY} />
@@ -173,7 +174,7 @@ it('should work with suspense disabled', async () => {
173174
});
174175

175176
it('should support query variables', async () => {
176-
const client = createClient(TASKS_MOCKS);
177+
const client = createClient({ mocks: TASKS_MOCKS });
177178
const { container } = render(
178179
<ApolloProvider client={client}>
179180
<Suspense fallback={<div>Loading</div>}>
@@ -193,7 +194,7 @@ it('should support query variables', async () => {
193194
});
194195

195196
it('should support updating query variables', async () => {
196-
const client = createClient(TASKS_MOCKS);
197+
const client = createClient({ mocks: TASKS_MOCKS });
197198
const { container, getByTestId, queryByTestId, rerender } = render(
198199
<ApolloProvider client={client}>
199200
<Suspense fallback={<div data-testid="loading">Loading</div>}>
@@ -235,7 +236,7 @@ it('should support updating query variables', async () => {
235236
});
236237

237238
it("shouldn't suspend if the data is already cached", async () => {
238-
const client = createClient(TASKS_MOCKS);
239+
const client = createClient({ mocks: TASKS_MOCKS });
239240
const { container, getByTestId, queryByTestId, rerender } = render(
240241
<ApolloProvider client={client}>
241242
<Suspense fallback={<div>Loading</div>}>
@@ -285,7 +286,7 @@ it("shouldn't suspend if the data is already cached", async () => {
285286
});
286287

287288
it("shouldn't allow a query with non-standard fetch policy with suspense", async () => {
288-
const client = createClient(TASKS_MOCKS);
289+
const client = createClient({ mocks: TASKS_MOCKS });
289290
/* eslint-disable no-console */
290291
const origConsoleError = console.error;
291292
console.error = jest.fn();
@@ -304,8 +305,86 @@ it("shouldn't allow a query with non-standard fetch policy with suspense", async
304305
/* eslint-enable no-console */
305306
});
306307

308+
it('should forward apollo errors', async () => {
309+
class ErrorBoundary extends React.Component {
310+
constructor(props) {
311+
super(props);
312+
this.state = { error: null };
313+
}
314+
315+
static getDerivedStateFromError(error) {
316+
return { error };
317+
}
318+
319+
render() {
320+
if (this.state.error) {
321+
// You can render any custom fallback UI
322+
return <p>Error occured: {this.state.error.message}</p>;
323+
}
324+
325+
return this.props.children;
326+
}
327+
}
328+
329+
const consoleErrorMock = jest
330+
.spyOn(console, 'error')
331+
.mockImplementation(() => {});
332+
333+
const linkReturningError = new ApolloLink(() => {
334+
return new Observable(observer => {
335+
observer.error(new Error('Simulating network error'));
336+
});
337+
});
338+
const client = createClient({ link: linkReturningError });
339+
340+
const { container } = render(
341+
<ErrorBoundary>
342+
<ApolloProvider client={client}>
343+
<Suspense fallback={<div>Loading</div>}>
344+
<TasksLoader query={TASKS_QUERY} />
345+
</Suspense>
346+
</ApolloProvider>
347+
</ErrorBoundary>
348+
);
349+
expect(container.textContent).toBe('Loading');
350+
flushEffects();
351+
await waitForNextTick();
352+
expect(container.textContent).toBe(
353+
'Error occured: Network error: Simulating network error'
354+
);
355+
356+
consoleErrorMock.mockRestore();
357+
});
358+
359+
it('should ignore apollo errors by default in non-suspense mode', async () => {
360+
const consoleErrorMock = jest
361+
.spyOn(console, 'error')
362+
.mockImplementation(() => {});
363+
364+
const linkReturningError = new ApolloLink(() => {
365+
return new Observable(observer => {
366+
observer.error(new Error('Simulating network error'));
367+
});
368+
});
369+
const client = createClient({ link: linkReturningError });
370+
const { container } = render(
371+
<ApolloProvider client={client}>
372+
<TasksLoaderWithoutSuspense query={TASKS_QUERY} />
373+
</ApolloProvider>
374+
);
375+
expect(container.textContent).toBe('Loading without suspense');
376+
flushEffects();
377+
await waitForNextTick();
378+
379+
expect(consoleErrorMock).toHaveBeenCalledTimes(1);
380+
expect(consoleErrorMock.mock.calls[0][1]).toBe(
381+
'Network error: Simulating network error'
382+
);
383+
consoleErrorMock.mockRestore();
384+
});
385+
307386
it('shouldn allow a query with non-standard fetch policy without suspense', async () => {
308-
const client = createClient(TASKS_MOCKS);
387+
const client = createClient({ mocks: TASKS_MOCKS });
309388
const { container } = render(
310389
<ApolloProvider client={client}>
311390
<TasksLoaderWithoutSuspense

src/__testutils__/createClient.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@ import { ApolloClient } from 'apollo-client';
22
import { InMemoryCache } from 'apollo-cache-inmemory';
33
import { MockLink } from 'apollo-link-mock';
44

5-
export default function createClient(mocks = []) {
5+
export default function createClient({ link, mocks = [] } = {}) {
66
return new ApolloClient({
77
cache: new InMemoryCache(),
8-
link: new MockLink(mocks),
8+
link: link ? link : new MockLink(mocks),
99
});
1010
}

src/index.js

Lines changed: 9 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@ import React, { useContext, useEffect, useRef, useState } from 'react';
22
import isEqual from 'react-fast-compare';
33

44
import deprecated from './deprecated';
5+
import objToKey from './objToKey';
6+
import {
7+
getCachedObservableQuery,
8+
invalidateCachedObservableQuery,
9+
} from './queryCache';
510

611
const ApolloContext = React.createContext();
712

@@ -15,22 +20,10 @@ export function useApolloClient() {
1520
return useContext(ApolloContext);
1621
}
1722

18-
export function useQuery(
19-
query,
20-
{
21-
variables,
22-
suspend = true,
23-
context: apolloContextOptions,
24-
...restOptions
25-
} = {}
26-
) {
23+
export function useQuery(query, { suspend = true, ...restOptions } = {}) {
2724
const client = useApolloClient();
2825
const [result, setResult] = useState();
2926
const previousQuery = useRef();
30-
// treat variables and context options separately because they are objects
31-
// and the other options are JS primitives
32-
const previousVariables = useRef();
33-
const previousApolloContextOptions = useRef();
3427
const previousRestOptions = useRef();
3528
const observableQuery = useRef();
3629

@@ -39,17 +32,13 @@ export function useQuery(
3932
const subscription = observableQuery.current.subscribe(nextResult => {
4033
setResult(nextResult);
4134
});
35+
invalidateCachedObservableQuery(client, query, restOptions);
4236

4337
return () => {
4438
subscription.unsubscribe();
4539
};
4640
},
47-
[
48-
query,
49-
objToKey(variables),
50-
objToKey(previousApolloContextOptions),
51-
objToKey(restOptions),
52-
]
41+
[query, objToKey(restOptions)]
5342
);
5443

5544
ensureSupportedFetchPolicy(restOptions.fetchPolicy, suspend);
@@ -65,20 +54,12 @@ export function useQuery(
6554
if (
6655
!(
6756
query === previousQuery.current &&
68-
isEqual(variables, previousVariables.current) &&
69-
isEqual(apolloContextOptions, previousApolloContextOptions.current) &&
7057
isEqual(restOptions, previousRestOptions.current)
7158
)
7259
) {
7360
previousQuery.current = query;
74-
previousVariables.current = variables;
75-
previousApolloContextOptions.current = apolloContextOptions;
7661
previousRestOptions.current = restOptions;
77-
const watchedQuery = client.watchQuery({
78-
query,
79-
variables,
80-
...restOptions,
81-
});
62+
const watchedQuery = getCachedObservableQuery(client, query, restOptions);
8263
observableQuery.current = watchedQuery;
8364
const currentResult = watchedQuery.currentResult();
8465
if (currentResult.partial && suspend) {
@@ -110,19 +91,6 @@ function ensureSupportedFetchPolicy(fetchPolicy, suspend) {
11091
}
11192
}
11293

113-
function objToKey(obj) {
114-
if (!obj) {
115-
return null;
116-
}
117-
const keys = Object.keys(obj);
118-
keys.sort();
119-
const sortedObj = keys.reduce((result, key) => {
120-
result[key] = obj[key];
121-
return result;
122-
}, {});
123-
return JSON.stringify(sortedObj);
124-
}
125-
12694
export const useApolloQuery = deprecated(
12795
useQuery,
12896
'useApolloQuery is deprecated, please use useQuery'

src/objToKey.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import isPlainObject from 'lodash/isPlainObject';
2+
3+
export default function objToKey(obj) {
4+
if (!obj) {
5+
return null;
6+
}
7+
const keys = Object.keys(obj);
8+
keys.sort();
9+
const sortedObj = keys.reduce((result, key) => {
10+
const value = obj[key];
11+
if (isPlainObject(value)) {
12+
result[key] = objToKey(obj[key]);
13+
} else {
14+
result[key] = obj[key];
15+
}
16+
return result;
17+
}, {});
18+
return JSON.stringify(sortedObj);
19+
}

src/queryCache.js

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { print } from 'graphql/language/printer';
2+
3+
import objToKey from './objToKey';
4+
5+
const cachedQueriesByClient = new WeakMap();
6+
7+
export function getCachedObservableQuery(client, query, options) {
8+
const queriesForClient = getCachedQueriesForClient(client);
9+
const cacheKey = getCacheKey(query, options);
10+
let observableQuery = queriesForClient.get(cacheKey);
11+
if (observableQuery == null) {
12+
observableQuery = client.watchQuery({ query, ...options });
13+
queriesForClient.set(cacheKey, observableQuery);
14+
}
15+
return observableQuery;
16+
}
17+
18+
export function invalidateCachedObservableQuery(client, query, options) {
19+
const queriesForClient = getCachedQueriesForClient(client);
20+
const cacheKey = getCacheKey(query, options);
21+
queriesForClient.delete(cacheKey);
22+
}
23+
24+
function getCachedQueriesForClient(client) {
25+
let queriesForClient = cachedQueriesByClient.get(client);
26+
if (queriesForClient == null) {
27+
queriesForClient = new Map();
28+
cachedQueriesByClient.set(client, queriesForClient);
29+
}
30+
return queriesForClient;
31+
}
32+
33+
function getCacheKey(query, options) {
34+
return `${print(query)}@@${objToKey(options)}`;
35+
}

0 commit comments

Comments
 (0)