Now that we’ve got unit testing set up and achieved good coverage with our initial tests, it’s time to go back to the plan and implement the next feature on the list. This time we’re looking at providing ScrollTo
and ScrollToItem
methods for our virtual list control.
The Plan
This is the last significant piece of functionality to validate with modern React. Adding your own custom methods to a classic class based React component is straight forward. It’s a class, just add a method. In contrast, adding methods to a modern function component is anything but. The theory is well documented. Wrap your function in a forwardRef
so that it can accept a ref
prop. Then use the useImperativeHandle
hook to bind the ref to a proxy object with the methods that you want to expose.
The same mechanism can be used to expose our own custom methods as well as forwarding standard DOM methods to internal HTML elements. In classic React you would often let clients bind refs directly to a component’s internal HTML element. Using a proxy object adds an abstraction layer which avoids directly exposing our component’s internal structure.
The equivalent class based react-window control allows you to pass both innerRef and outerRef props which bind to the inner and outer divs of the implementation. The forwardRef
mechanism used for function components only supports a single ref. However, by using a proxy object we hide the details of which inner HTML elements are involved. We can expose methods that interact with either the inner or outer divs as appropriate.
Test Driven Development
Honestly, I started with the best intentions. I was going to try and do proper Test Driven Development.
- Write a unit test.
- Write the interface and stub implementation needed to to make it “compile”.
- Confirm that the test fails.
- Write enough implementation to make the test pass.
- Rinse and repeat
I find it really hard to get a grasp on the big picture doing it that way. In my view the important thing is to think a step or two ahead. Design your interfaces for testability but also make sure the pieces will eventually fit together.
I’m going to start in the middle by writing the interface and stub implementation, including the TypeScript typing. Then I can validate interface usability by writing test cases that interact with it. That includes seeing how well the tooling, such as Intellisense, helps me. Writing the implementation still comes last.
Here’s the significant parts of the interface and stub implementation.
export interface VirtualListProxy {
scrollTo(offset: number): void;
scrollToItem(index: number): void;
};
export const VirtualList = React.forwardRef<VirtualListProxy, VirtualListProps>((props, ref) => {
const { itemOffsetMapping } = props;
const outerRef = React.useRef<HTMLDivElement>(null);
React.useImperativeHandle(ref, () => {
return {
scrollTo(offset: number): void {
outerRef.current?.scrollTo(0, offset);
},
scrollToItem(index: number): void {
this.scrollTo(itemOffsetMapping.itemOffset(index));
}
}
}, [ itemOffsetMapping ]);
...
The proxy object has a simple scrollTo
offset method and scrollToItem
. I can add more methods over time.
My simple function VirtualList
gets turned into a messy looking const VirtualList = forwardRef<>(() => ...
expression. However, typing works out nicely. forwardRef
is defined as generic on the wrapped component’s props and the ref type. I need to declare each type once and TypeScript infers the rest.
scrollTo
just forwards on to the equivalent method on the outer div element. The list control supports scrolling in one dimension only, so no point allowing both x and y scroll arguments.
scrollToItem
is then a simple one liner that converts item index to offset and calls scrollTo
. I need to make use of the itemOffsetMapping
prop to do the conversion which means it needs to be declared as a dependency for useImperativeHandle.
Oh dear, I seem to have ended up implementing it too. So much for TDD. At least I did write the unit test before trying it out in my sample app.
const ref = React.createRef<VirtualListProxy>();
render(
<VirtualList
ref={ref}
height={240}
itemCount={100}
itemOffsetMapping={mapping}
width={600}>
{Cell}
</VirtualList>
)
const proxy = ref.current || throwErr("null ref");
{act(() => {
proxy.scrollTo(100);
})}
...
Intellisense works well. If I try to create an untyped ref with React.createRef()
, I get an Intellisense error that tells me I need to pass a Ref<VirtualListProxy>
as the ref
prop. Then when I try to use the proxy I get Intellisense auto-complete suggestions for the available methods.
Unfortunately, I get a failure at runtime when I execute the test.
TypeError: outerRef.current?.scrollTo is not a function
❯ Object.scrollTo src/VirtualList.tsx:126:65
❯ src/VirtualList.test.tsx:113:20
At first I thought I was dealing with some weird TypeScript error. Then I realized that the error wasn’t shown in the IDE as I typed and came with a runtime call stack. I still had no idea what was going on but once more the internet came to my rescue. The problem is that scrollTo
is one of the small list of unimplemented methods in jsdom.
The best I can do as far as a unit test goes is to provide a mock implementation and validate that it’s called with the expected arguments. I could implement enough that I could also exercise the underlying functionality by setting scrollTop
and sending scroll events, but that would only duplicate what I’m doing in my existing tests that send scroll events.
I wanted to use vi.spyOn
to create and install my mock as I could then use vi.restoreAllMocks
in an afterEach
to cleanup. Unfortunately, spyOn
refuses to install the mock if there’s no existing function. As I only need this for a single test, it was easier to setup and teardown manually.
const mock = vi.fn();
Element.prototype["scrollTo"] = mock;
try {
...
proxy.scrollTo(100);
expect(mock).toBeCalledWith(0, 100);
proxy.scrollToItem(42);
expect(mock).toBeCalledWith(0, 42*30);
} finally {
Reflect.deleteProperty(Element.prototype, "scrollTo");
}
I don’t need to wrap my call to scrollTo
in an act
as it’s being mocked and no React state gets updated.
End to End Testing
It does mean that I’m back to manual testing to validate that scrollTo
works end to end as expected. In future I might look at Playwright or similar for automated end to end tests.
I added an input field to my test app and hooked it up to the scrollToItem
method. It all seemed to work, with a couple of annoyances.
The OnChange handler on the input field fires on every key stroke which gets annoyingly jumpy. The HTML change event is only meant to fire on enter or when the control loses focus. It turns out that OnChange in React behaves differently. It intentionally fires on any change, perhaps in order to be more “reactive”.
The other annoyance is that I can’t scroll to index 0. The closest I can get to the top is “Item 1”. My first thought was that there was something wrong with the code I’d just written or that I’d misunderstood how ScrollTo
works.
It turns out that I have a bug in my extensively unit tested, 100% code coverage, useVariableSizeItemOffsetMapping.itemOffset
method. Which reinforces the point that 100% code coverage doesn’t mean 100% tested.
I did the right thing when I fixed it. First add a unit test case that reproduces the bug, fix it, then confirm that the test passes.
proxy.scrollToItem(0);
expect(mock).toBeCalledWith(0, 0);
Try It!
As ever, feel free to try it out for yourself.