-
Notifications
You must be signed in to change notification settings - Fork 233
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’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add Suspense and ErrorBoundary to better support suspending from hooks #35
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,12 +1,37 @@ | ||
import React from 'react' | ||
import React, { Suspense } from 'react' | ||
import { render, cleanup, act } from 'react-testing-library' | ||
|
||
function TestHook({ callback, hookProps, children }) { | ||
try { | ||
children(callback(hookProps)) | ||
} catch (e) { | ||
children(undefined, e) | ||
children(callback(hookProps)) | ||
return null | ||
} | ||
|
||
class ErrorBoundary extends React.Component { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I wish there was a non-class way to handle error boundaries |
||
constructor(props) { | ||
super(props) | ||
this.state = { hasError: false } | ||
} | ||
|
||
static getDerivedStateFromError() { | ||
return { hasError: true } | ||
} | ||
|
||
componentDidCatch(error) { | ||
this.props.onError(error) | ||
} | ||
|
||
componentDidUpdate(prevProps) { | ||
if (this.props != prevProps && this.state.hasError) { | ||
this.setState({ hasError: false }) | ||
} | ||
} | ||
|
||
render() { | ||
return !this.state.hasError && this.props.children | ||
} | ||
} | ||
|
||
function Fallback() { | ||
return null | ||
} | ||
|
||
|
@@ -27,27 +52,34 @@ function resultContainer() { | |
} | ||
} | ||
|
||
const updateResult = (val, err) => { | ||
value = val | ||
error = err | ||
resolvers.splice(0, resolvers.length).forEach((resolve) => resolve()) | ||
} | ||
|
||
return { | ||
result, | ||
addResolver: (resolver) => { | ||
resolvers.push(resolver) | ||
}, | ||
updateResult: (val, err) => { | ||
value = val | ||
error = err | ||
resolvers.splice(0, resolvers.length).forEach((resolve) => resolve()) | ||
} | ||
setValue: (val) => updateResult(val), | ||
setError: (err) => updateResult(undefined, err) | ||
} | ||
} | ||
|
||
function renderHook(callback, { initialProps, ...options } = {}) { | ||
const { result, updateResult, addResolver } = resultContainer() | ||
const { result, setValue, setError, addResolver } = resultContainer() | ||
const hookProps = { current: initialProps } | ||
|
||
const toRender = () => ( | ||
<TestHook callback={callback} hookProps={hookProps.current}> | ||
{updateResult} | ||
</TestHook> | ||
<ErrorBoundary onError={setError}> | ||
<Suspense fallback={<Fallback />}> | ||
<TestHook callback={callback} hookProps={hookProps.current}> | ||
{setValue} | ||
</TestHook> | ||
</Suspense> | ||
</ErrorBoundary> | ||
) | ||
|
||
const { unmount, rerender: rerenderComponent } = render(toRender(), options) | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,51 @@ | ||
import { renderHook, cleanup } from 'src' | ||
|
||
describe('suspense hook tests', () => { | ||
const cache = {} | ||
const fetchName = (isSuccessful) => { | ||
if (!cache.value) { | ||
cache.value = new Promise((resolve, reject) => { | ||
setTimeout(() => { | ||
if (isSuccessful) { | ||
resolve('Bob') | ||
} else { | ||
reject(new Error('Failed to fetch name')) | ||
} | ||
}, 50) | ||
}) | ||
.then((value) => (cache.value = value)) | ||
.catch((e) => (cache.value = e)) | ||
} | ||
return cache.value | ||
} | ||
|
||
const useFetchName = (isSuccessful = true) => { | ||
const name = fetchName(isSuccessful) | ||
if (typeof name.then === 'function' || name instanceof Error) { | ||
throw name | ||
} | ||
return name | ||
} | ||
|
||
beforeEach(() => { | ||
delete cache.value | ||
}) | ||
|
||
afterEach(cleanup) | ||
|
||
test('should allow rendering to be suspended', async () => { | ||
const { result, waitForNextUpdate } = renderHook(() => useFetchName(true)) | ||
|
||
await waitForNextUpdate() | ||
|
||
expect(result.current).toBe('Bob') | ||
}) | ||
|
||
test('should set error if suspense promise rejects', async () => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not sure this test actually adds much value. in #27 I discovered that a rejected There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Anyone who implements a Suspense library will be doing such so I think this provides value There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, that was my thought too and why I kept it when cleaning up the commits. |
||
const { result, waitForNextUpdate } = renderHook(() => useFetchName(false)) | ||
|
||
await waitForNextUpdate() | ||
|
||
expect(result.error).toEqual(new Error('Failed to fetch name')) | ||
}) | ||
}) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If you kept this block an just re-threw you could also grab any promises to store in a third result value of 'promise' or the like. While waitForNextUpdate() will work in most cases, inspecting the promise thrown would be useful in test cases.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(If you rethrow the exact same error it will preserve the original stack trace.)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The intention of removing this and using a
Suspense
andErrorBoundary
wrapper was to protect this library from having to deal with the internals of how error handling and suspended rendering actually works. The fact that a thrown promise is what triggers react to suspend is an implementation for React, rather than something we should be relying on.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
you're still setting error property though, right? Seems like you are somewhat depending on react in that manner. regardless - this is a react hooks testing library. very specific to how react works.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes,
error
is being set, but it's coming from the exposedcomponentDidCatch
API, so we don't care how React gets it to that point, just deal with it when it gets there. When we look at theSuspense
equivalent, React does not expose this to us in any way, so if we want to handle it, we essentially need to recreate react's internal logic in order to treat it the same way. If React changes the specifics on how it deals with promises, we would also need to change.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It should. I'd rather fix holes where it doesn't than maintain custom suspense handling.
This solution would not end up throwing out of the
renderHook
function, if that's what you mean?Can you unpack this a bit more? Are you concerned about when an update was triggered by something other than the promise resolving, or something else?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
One path is that no promise is thrown from the hook - I want to test that it actually threw a promise, and THEN did something else
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I guess I'm questioning the value of having that much pwoer to inspect the internals of what's happening in your hook (i.e. white box test) vs. just asserting the output from the given inputs (i.e. black box test).
In my experience, white box test are very fragile to changes in the system and need constant updating as the internals shift around (e.g. you might change the condition for when the promise is thrown, so now would need to move things around in your tests to match the change). The black box tests are more likely to continue to work so, long as the contract of the inputs and outputs hasn't changes (otherwise the test must be changed anyway in both testing styles).
In cases where you need more control over when and how the promise thrown, then you may be better off extracting it into some form of factory pattern and mocking that behaviour in the hook test, and testing those variations of the factory seperately to the hook itself.
As an escape hatch, if you really need access to the promise itself, I think you could just catch it in the hool callback itself
I'm still unsure how that is more useful than
As in both cases
await
null
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm also leaning away from explicitly handling supsense as it would get very difficult to manage if you had a hook throw a promise, and then once resolved throw another promise, etc. Suspense would keep that component suspending untill it eventually rendered something, whereas keeping track of each one and what they were up to could get complicated.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I mean, I would argue that suspense is very much an explicit interface and not an implementation detail - as anyone using the hook that throws promises will need to define suspense boundaries themselves. It's not about how but if.
Yes it could throw many times - that should be up to the test writer to understand exactly what that behavior should be - or if it even cares at all. I agree in many cases you won't care, and you can simply waitForUpdate. But there are certainly cases where you will.
(I responded slowly this time due to company hackathon last week and then I was out traveling over weekend)