Skip to content

Commit 11f5e9f

Browse files
committed
feat(errorReporting): error reporting happens now in error boundary
1 parent 41903b1 commit 11f5e9f

File tree

9 files changed

+152
-87
lines changed

9 files changed

+152
-87
lines changed

.eslintrc.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
// Keep this file for editor support
2+
module.exports = require('@researchgate/spire-config/eslint/react');

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@ yarn-error.log
33
/node_modules
44
/lib
55
/src/coverage
6-
/.docs
6+
/.docs
7+
.DS_Store

README.md

Lines changed: 45 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ all the imperative parts for you.
4141
- [Documentation](#documentation)
4242
- [Demos](#demos)
4343
- [Recipes](#recipes)
44-
- [Exception handling](#exception-handling)
44+
- [Handling a missing DOM node situation](#handling-a-missing-dom-node-situation)
4545
- [Options](#options)
4646
- [Notes](#notes)
4747
- [Polyfill](#polyfill)
@@ -208,35 +208,63 @@ import ViewableMonitor from './ViewableMonitor';
208208

209209
export default () => (
210210
<ViewableMonitor>
211-
{isViewable => (isViewable ? 'I am viewable' : 'I am still hiding')}
211+
{(isViewable) => (isViewable ? 'I am viewable' : 'I am still hiding')}
212212
</ViewableMonitor>
213213
);
214214
```
215215

216216
Discover more recipes in our [examples section](docs/README.md).
217217

218-
### Exception handling
218+
### Handling a missing DOM node situation
219219

220-
By default, an `invariant` error is thrown if there isn't a DOM node to observe
221-
when mounted. We've added a helpful and descriptive message that's supposed to
222-
help you identify potential problems early on. However, this could also
223-
unexpectedtly trigger in production, specially with dynamic children, causing a
224-
bigger problem (bubbling up) if not handled correctly (e.g.: error boundary). In
225-
such cases, you can set the config for the error reporter to adjust it to your
226-
needs:
220+
In cases where there isn't a DOM node available to observe when rendering,
221+
you'll be seeing an error logged in the console:
222+
223+
```js
224+
ReactIntersectionObserver: Can't find DOM node in the provided children. Make sure to render at least one DOM node in the tree.
225+
```
226+
227+
This somewhat helpful and descriptive message is supposed to help you identify
228+
potential problems implementing `observers` early on. If you miss the exception
229+
for some reason and ends up in production (prone to happen with dynamic
230+
children), this component will NOT unmount. Instead, it will gracefully catch
231+
the error so that you can do custom logging and report it. For example:
227232
228233
```js
229234
import { Config } from '@researchgate/react-intersection-observer';
230235
231-
Config.errorReporter(function(errorMsg) {
232-
if (process.env.NODE_ENV === 'production') {
233-
sendReport((errorMsg);
234-
} else {
235-
console.error(errorMsg);
236-
}
236+
if (process.env.NODE_ENV === 'production') {
237+
Config.errorReporter(function(error) {
238+
sendReport(error);
239+
});
240+
}
241+
```
242+
243+
Maybe you want to deal with the error on your own, for example, by rendering a
244+
fallback. In that case, you can re-throw the error so that it bubbles up to the
245+
next boundary:
246+
247+
```js
248+
import { Config } from '@researchgate/react-intersection-observer';
249+
250+
Config.errorReporter(function(error) {
251+
throw error;
237252
});
238253
```
239254
255+
If this error happens during mount, it's easy to spot. However, a lot of these
256+
errors usually happen during tree updates, because some child component that was
257+
previously observed suddently ceaces to exist in the UI. This usually means that
258+
either you shouldn't have rendered an `<Observer>` around it anymore or, you
259+
should have used the `disabled` property.
260+
261+
At [ResearchGate](www.researchgate.net), we have found that not unmounting the
262+
tree just because we failed to `observe()` a DOM node suits our use cases
263+
better. It's fairly common having a lack of error boundaries around your
264+
components, and that leads to entire UIs parts being unmounted, which is not
265+
ideal to end users. By capturing errors, we are able to keep the UI unbroken
266+
while we fix them.
267+
240268
### Options
241269

242270
**root**: `HTMLElement|string` | default `window object`
@@ -313,7 +341,7 @@ might want to decorate your `onChange` callback with a `requestIdleCallback` or
313341
`setTimeout` call to avoid a potential performance degradation:
314342
315343
```js
316-
onChange = entry => requestIdleCallback(() => this.handleChange(entry));
344+
onChange = (entry) => requestIdleCallback(() => this.handleChange(entry));
317345
```
318346
319347
## [**IntersectionObserver**'s Browser Support](https://platform-status.mozilla.org/)

package.json

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
"url": "https://github.com/researchgate/react-intersection-observer/issues"
1111
},
1212
"dependencies": {
13-
"invariant": "^2.2.4",
1413
"prop-types": "^15.7.2"
1514
},
1615
"devDependencies": {
@@ -67,6 +66,7 @@
6766
"**/__tests__/**/*.spec.js"
6867
]
6968
},
69+
"prettier": "@researchgate/prettier-config",
7070
"spire": {
7171
"extends": "@researchgate/spire-config",
7272
"plugins": [
@@ -85,6 +85,5 @@
8585
"storybook": "start-storybook -p 9001 -c .storybook",
8686
"test": "spire test",
8787
"ts:check": "tsc --project types"
88-
},
89-
"prettier": "@researchgate/prettier-config"
88+
}
9089
}

src/IntersectionObserver.js

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -110,10 +110,9 @@ class IntersectionObserver extends React.Component {
110110
return false;
111111
}
112112
if (!this.targetNode) {
113-
Config.errorReporter(
113+
throw new Error(
114114
"ReactIntersectionObserver: Can't find DOM node in the provided children. Make sure to render at least one DOM node in the tree."
115115
);
116-
return false;
117116
}
118117
this.observer = createObserver(getOptions(this.props));
119118
this.target = this.targetNode;
@@ -184,4 +183,22 @@ class IntersectionObserver extends React.Component {
184183
}
185184
}
186185

187-
export { IntersectionObserver as default, getOptions };
186+
class GuardedIntersectionObserver extends React.Component {
187+
static displayName = 'ErrorBoundary(IntersectionObserver)';
188+
189+
componentDidCatch(error, info) {
190+
if (Config.errorReporter) {
191+
Config.errorReporter(error, info);
192+
}
193+
}
194+
195+
render() {
196+
return <IntersectionObserver {...this.props} />;
197+
}
198+
}
199+
200+
export {
201+
GuardedIntersectionObserver as default,
202+
IntersectionObserver,
203+
getOptions,
204+
};

src/__tests__/IntersectionObserver.spec.js

Lines changed: 72 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22
import 'intersection-observer';
33
import React, { Component } from 'react';
44
import renderer from 'react-test-renderer';
5-
import IntersectionObserver, { getOptions } from '../IntersectionObserver';
5+
import GuardedIntersectionObserver, {
6+
IntersectionObserver,
7+
getOptions,
8+
} from '../IntersectionObserver';
69
import { callback, observerElementsMap } from '../observer';
710
import Config from '../config';
811

@@ -19,6 +22,8 @@ jest.mock('react-dom', () => {
1922
},
2023
};
2124
});
25+
// the default "undefined" can't be re-assigned, so we preemptively set it as an empty function
26+
Config.errorReporter = function() {};
2227

2328
const target = { nodeType: 1, type: 'span' };
2429
const targets = { div: { nodeType: 1, type: 'div' }, span: target };
@@ -71,6 +76,71 @@ test('throws trying to observe children without a DOM node', () => {
7176
expect(observerElementsMap.size).toBe(sizeBeforeObserving);
7277
});
7378

79+
test('reports error trying to observe children without a DOM node', () => {
80+
global.spyOn(console, 'error'); // suppress error boundary warning
81+
const sizeBeforeObserving = observerElementsMap.size;
82+
const originalErrorReporter = Config.errorReporter;
83+
const spy = jest.fn();
84+
Config.errorReporter = spy;
85+
86+
const tree = renderer
87+
.create(
88+
<GuardedIntersectionObserver onChange={noop}>
89+
<ProxyComponent>{null}</ProxyComponent>
90+
</GuardedIntersectionObserver>
91+
)
92+
.toTree();
93+
94+
expect(observerElementsMap.size).toBe(sizeBeforeObserving);
95+
expect(spy).toBeCalledTimes(1);
96+
expect(spy).toBeCalledWith(
97+
expect.any(Error),
98+
expect.objectContaining({ componentStack: expect.any(String) })
99+
);
100+
// Tree stayed mounted because of the error boundary
101+
expect(tree.props.children.type).toEqual(ProxyComponent);
102+
103+
Config.errorReporter = originalErrorReporter;
104+
});
105+
106+
test('reports errors by re-throwing trying observer children without a DOM node', () => {
107+
global.spyOn(console, 'error'); // suppress error boundary warning
108+
const originalErrorReporter = Config.errorReporter;
109+
let called = false;
110+
Config.errorReporter = (err) => {
111+
called = true;
112+
throw err;
113+
};
114+
class TestErrorBoundary extends React.Component {
115+
state = { hasError: false };
116+
117+
componentDidCatch() {
118+
this.setState({ hasError: true });
119+
}
120+
121+
render() {
122+
// eslint-disable-next-line react/prop-types
123+
return this.state.hasError ? 'has-error' : this.props.children;
124+
}
125+
}
126+
127+
const children = renderer
128+
.create(
129+
<TestErrorBoundary>
130+
<GuardedIntersectionObserver onChange={noop}>
131+
<ProxyComponent>{null}</ProxyComponent>
132+
</GuardedIntersectionObserver>
133+
</TestErrorBoundary>
134+
)
135+
.toJSON();
136+
137+
// Tree changed because of the custom error boundary
138+
expect(children).toBe('has-error');
139+
expect(called).toBe(true);
140+
141+
Config.errorReporter = originalErrorReporter;
142+
});
143+
74144
test('should not observe children that equal null or undefined', () => {
75145
const sizeBeforeObserving = observerElementsMap.size;
76146
renderer.create(
@@ -348,37 +418,7 @@ describe('updating', () => {
348418
expect(observe).toBeCalledTimes(1);
349419
});
350420

351-
test('should call a setup errorReporter without a DOM Node', () => {
352-
const spy = jest.fn();
353-
const origErrorReporter = Config.errorReporter;
354-
Config.errorReporter = spy;
355-
const tree = renderer.create(
356-
<IntersectionObserver onChange={noop}>
357-
<ProxyComponent>
358-
<div />
359-
</ProxyComponent>
360-
</IntersectionObserver>,
361-
{ createNodeMock }
362-
);
363-
const instance = tree.getInstance();
364-
const observe = jest.spyOn(instance, 'observe');
365-
const unobserve = jest.spyOn(instance, 'unobserve');
366-
367-
tree.update(
368-
<IntersectionObserver onChange={noop}>
369-
<ProxyComponent key="forcesRender">{null}</ProxyComponent>
370-
</IntersectionObserver>
371-
);
372-
373-
expect(spy).toBeCalled();
374-
expect(unobserve).toBeCalledTimes(1);
375-
expect(observe).toBeCalledTimes(1);
376-
expect(observe).toReturnWith(false);
377-
378-
Config.errorReporter = origErrorReporter;
379-
});
380-
381-
test('should observe when updating with a DOM Node', () => {
421+
test('should observe only when updating with a DOM Node', () => {
382422
global.spyOn(console, 'error'); // suppress error boundary warning
383423

384424
const sizeAfterUnobserving = observerElementsMap.size;

src/__tests__/config.spec.js

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,20 @@
11
/* eslint-env jest */
22
import Config from '../config';
3-
import invariant from 'invariant';
4-
5-
jest.mock('invariant', () => jest.fn());
63

74
describe('Config', () => {
8-
const testErrorReporter = jest.fn();
9-
const errorMsg = 'Intentionally throw exception';
10-
115
test('default errorReporter', () => {
12-
Config.errorReporter(errorMsg);
13-
expect(invariant).toBeCalledWith(false, errorMsg);
6+
expect(Config.errorReporter).toBeUndefined();
147
});
158

169
test('custom errorReporter', () => {
17-
const defaultTestErrorReporter = Config.errorReporter;
18-
Config.errorReporter = testErrorReporter;
19-
20-
Config.errorReporter(errorMsg);
21-
expect(testErrorReporter).toBeCalledWith(errorMsg);
10+
const testErrorReporter = jest.fn();
11+
const errorMsg = 'Intentionally throw exception';
12+
const infoMsg = 'Meta information for exception';
13+
const InlineConfig = require('../config');
14+
InlineConfig.errorReporter = testErrorReporter;
15+
InlineConfig.errorReporter(errorMsg, infoMsg);
2216

23-
Config.errorReporter = defaultTestErrorReporter;
17+
expect(testErrorReporter).toBeCalledWith(errorMsg, infoMsg);
2418
});
2519

2620
test('custom non-callable errorReporter', () => {

src/config.js

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,10 @@
1-
import invariant from 'invariant';
2-
31
const config = {};
42

53
export default Object.create(null, {
64
errorReporter: {
75
configurable: false,
86
get() {
9-
return (
10-
config.errorReporter ||
11-
function(format) {
12-
return invariant(false, format);
13-
}
14-
);
7+
return config.errorReporter;
158
},
169
set(value) {
1710
if (typeof value !== 'function') {

yarn.lock

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7241,7 +7241,6 @@ fsevents@^1.2.7:
72417241
dependencies:
72427242
bindings "^1.5.0"
72437243
nan "^2.12.1"
7244-
node-pre-gyp "*"
72457244

72467245
fsevents@^2.1.2:
72477246
version "2.1.2"
@@ -11289,7 +11288,6 @@ npm@^6.10.3:
1128911288
cmd-shim "^3.0.3"
1129011289
columnify "~1.5.4"
1129111290
config-chain "^1.1.12"
11292-
debuglog "*"
1129311291
detect-indent "~5.0.0"
1129411292
detect-newline "^2.1.0"
1129511293
dezalgo "~1.0.3"
@@ -11304,7 +11302,6 @@ npm@^6.10.3:
1130411302
has-unicode "~2.0.1"
1130511303
hosted-git-info "^2.8.7"
1130611304
iferr "^1.0.2"
11307-
imurmurhash "*"
1130811305
infer-owner "^1.0.4"
1130911306
inflight "~1.0.6"
1131011307
inherits "^2.0.4"
@@ -11323,14 +11320,8 @@ npm@^6.10.3:
1132311320
libnpx "^10.2.2"
1132411321
lock-verify "^2.1.0"
1132511322
lockfile "^1.0.4"
11326-
lodash._baseindexof "*"
1132711323
lodash._baseuniq "~4.6.0"
11328-
lodash._bindcallback "*"
11329-
lodash._cacheindexof "*"
11330-
lodash._createcache "*"
11331-
lodash._getnative "*"
1133211324
lodash.clonedeep "~4.5.0"
11333-
lodash.restparam "*"
1133411325
lodash.union "~4.6.0"
1133511326
lodash.uniq "~4.5.0"
1133611327
lodash.without "~4.4.0"

0 commit comments

Comments
 (0)