Skip to content

Commit 841933c

Browse files
committed
04/03: rewrite solution, add screenshots
1 parent 0d3cac8 commit 841933c

12 files changed

+222
-232
lines changed

Diff for: exercises/04.debugging/03.problem.breakpoints/src/main-menu.browser.test.tsx

+16-22
Original file line numberDiff line numberDiff line change
@@ -4,26 +4,20 @@ import { MemoryRouter } from 'react-router'
44
import { MainMenu } from './main-menu'
55

66
test('renders the currently active menu link', async () => {
7-
// 🐨 Render the <MainMenu /> component.
8-
// Use a custom `wrapper` and `<MemoryRouter />` from "react-router"
9-
// so the rendered component can access routing-related data.
10-
//
11-
// 🐨 Create a variable called `allLinks` and assign it the result
12-
// of locating all elements with the role "link".
13-
// 💰 page.getByRole(role).elements()
14-
//
15-
// 🐨 Create another variable called `currentPageLink`.
16-
// For its value, try to find a link element from `allLinks`
17-
// whose "aria-current" attribute equals to "page".
18-
// 💰 allLinks.find(predicate)!
19-
// 💰 element.getAttribute(attributeName)
20-
//
21-
// 🐨 Finally, write an assertion that the `currentPageLink`
22-
// has acessible name that equals to "Analytics".
23-
// await expect.element(locator).toHaveAccessibleName(name)
24-
//
25-
// Once you run this test, you will notice it's failing.
26-
// 🐨 See what's wrong by adding a conditional breakpoint
27-
// somewhere during the <MenuItemsList /> rendering.
28-
// For the condition, use the title of the wrong active link.
7+
render(<MainMenu />, {
8+
wrapper({ children }) {
9+
return (
10+
<MemoryRouter initialEntries={['/dashboard/analytics']}>
11+
{children}
12+
</MemoryRouter>
13+
)
14+
},
15+
})
16+
17+
const allLinks = page.getByRole('link').elements()
18+
const currentPageLink = allLinks.find(
19+
(link) => link.getAttribute('aria-current') === 'page',
20+
)!
21+
22+
await expect.element(currentPageLink).toHaveAccessibleName('Analytics')
2923
})

Diff for: exercises/04.debugging/03.solution.breakpoints/README.mdx

+107-102
Original file line numberDiff line numberDiff line change
@@ -34,15 +34,15 @@ Because I still want to be able to debug those steps, I will add _breakpoints_ t
3434
3535
## Conditional breakpoints
3636

37-
Speaking of more powers, it's time to put them to the test! But not before I finish the new test case for the `<MainMenu />` component.
37+
Speaking of more powers, it's time to put them to good use! But not before I finish the new test case for the `<MainMenu />` component.
3838

39-
As the name suggests, the menu component renders the main navigation menu for our application. To do that, it recursively iterates over the `menuItems` array to render those items and their possible children. This is, effectively, a loop with a base case and a recursive case:
39+
As the name suggests, the menu component renders the main navigation for our application. To do that, it recursively iterates over the `menuItems` array to render those items and their possible children. This is, effectively, a loop with a base case and a recursive case:
4040

4141
```tsx filename=main-menu.tsx nonumber nocopy highlight=7,9
4242
{
4343
props.items.map((item) => (
4444
<li key={item.url}>
45-
<NavLink>{item.title}</NavLink>
45+
<NavLink to={item.url}>{item.title}</NavLink>
4646
{item.children ? (
4747
// Recursive case
4848
<MenuItemsList items={item.children} />
@@ -53,68 +53,9 @@ As the name suggests, the menu component renders the main navigation menu for ou
5353
}
5454
```
5555

56-
When rendering individual menu items, the component checks whether that item is active by matching its URL against the current location:
57-
58-
```tsx filename=main-menu.tsx highlight=4,6
59-
import { useLocation, matchPath } from 'react-router'
60-
61-
function MainMenuList() {
62-
const location = useLocation()
63-
// ...
64-
const isActive = matchPath({ path: item.url, end: false }, location.pathname)
65-
}
66-
```
67-
68-
This dependency on `react-router` means I need to create a custom `wrapper` in order for the menu component to render at all:
69-
70-
```tsx filename=main-menu.browser.test.tsx nonumber
71-
render(<MainMenu />, {
72-
wrapper({ children }) {
73-
return <MemoryRouter>{children}</MemoryRouter>
74-
},
75-
})
76-
```
77-
78-
Since this test checks the currently active page, I need to somehow simulate the route that we are currently on in the application. I can do that by providing the path I want to the `initialEntries` prop of `<MemoryRouter />`:
79-
80-
```tsx filename=main-menu.browser.test.tsx highlight=4 nonumber
81-
render(<MainMenu />, {
82-
wrapper({ children }) {
83-
return (
84-
<MemoryRouter initialEntries={['/dashboard/analytics']}>
85-
{children}
86-
</MemoryRouter>
87-
)
88-
},
89-
})
90-
```
91-
92-
Now, I need to check that the corresponding menu item is actually marked as the current page. Because the current page is indicated using the `aria-current` attribute set to `'page'`, I cannot directly locate that element. Instead, I will get all the rendered links and then find the one with that attribute set to the right value:
93-
94-
```tsx filename=main-menu.browser.test.tsx highlight=1,3 nonumber
95-
const allLinks = page.getByRole('link').elements()
96-
const currentPageLink = allLinks.find(
97-
(link) => link.getAttribute('aria-current') === 'page',
98-
)!
99-
```
100-
101-
> 🦉 `page.getByRole('link')` returns a locator you can resolve in different ways. By default, it points to a single element matching the locator, but you can call its `.elements()` method to resolve it to a _list_ of matching HTML elements. This roughly translates to `.findAllByRole('link')` from RTL.
102-
103-
And I'm going to conclude this test by adding the following assertion:
104-
105-
```tsx filename=main-menu.browser.test.tsx nonumber
106-
await expect.element(currentPageLink).toHaveAccessibleName('Analytics')
107-
```
108-
109-
This single assertion allows me to check multiple things at once:
110-
111-
- The `currentPageLink` element could be located (i.e. is rendered by the menu);
112-
- The `currentPageLink` element has accessible name of `'Analytics'`, which in case of `role="link"` elements is derived from their text content. This way, I'm checking that the link is both visible to the screen-readers and has the correct text content.
113-
114-
But running this test presents me with an error:
115-
116-
```txt highlight=12-15 nonumber
56+
But for some unknown reason, when I run the automated test, the link I expect to be active isn't the only one active!
11757

58+
```shell nonumber highlight=11-14
11859
❯ src/main-menu.browser.test.tsx:22:39
11960
20| )!
12061
21|
@@ -131,66 +72,130 @@ Received:
13172
Dashboard
13273
```
13374

134-
Looks like the current page link that gets rendered is the "Dashboard" link and not "Analytics". This demands some debugging.
75+
Something is definitely off here, and I can use breakpoints to help me find the root cause.
76+
77+
There's just a slight problem. Since the `<MainMenu />` component is recursive, if I put a breakpoint where I think is a good place to start debugging, it will trigger _for all rendered menu items_. That's not nice because I will be spending my time jumping between breakpoints instead of actually solving the issue.
78+
79+
This is where _conditional breakpoints_ can be a huge time-savior. They act just like the regulat breakpoints but they only trigger when their condition resolves to true. So I can say if `item.title === 'Dashboard'`, stop the execution. That's neat!
80+
81+
But not so fast. Conditional breakpoints can only access variables present in the _current scope_ (i.e. the scope where you are placing a breakpoint), and the `item` variable comes from the parental scope, which will make it impossible to use in the condition.
82+
83+
```tsx filename=main-menu.tsx nocopy nonumber highlight=12-13
84+
function MenuItemsList({ items }: { items: Array<MenuItem> }) {
85+
return (
86+
<ul className="ml-4">
87+
{items.map((item) => {
88+
return (
89+
<li key={item.url}>
90+
<NavLink
91+
to={item.url}
92+
className={({ isActive }) =>
93+
[
94+
'px-2 py-1 hover:text-blue-600 hover:underline',
95+
// I'd like to put a conditional breakpoints somewhere here,
96+
// but the `item` variable isn't defined or referenced in this scope.
97+
isActive ? 'font-bold text-black' : 'text-gray-600',
98+
].join(' ')
99+
}
100+
>
101+
{item.title}
102+
</NavLink>
103+
{item.children ? <MenuItemsList items={item.children} /> : null}
104+
</li>
105+
)
106+
})}
107+
</ul>
108+
)
109+
}
110+
```
111+
112+
Luckily, I can fix this by referencing the `item` inside the `className` function's scope:
113+
114+
```tsx filename=main-menu.tsx nocopy remove=6 add=7-9
115+
<NavLink
116+
to={item.url}
117+
className={({ isActive }) =>
118+
[
119+
'px-2 py-1 hover:text-blue-600 hover:underline',
120+
isActive ? 'font-bold text-black' : 'text-gray-600',
121+
// Referencing the `item` variable in this closure will make it
122+
// possible to use for the conditional breakpoint.
123+
item && isActive ? 'font-bold text-black' : 'text-gray-600',
124+
].join(' ')
125+
}
126+
>
127+
```
135128

136-
Now, there are many ways you can approach debugging this. You can go through the source code or maybe inspect the DOM via the `debug()` method from `render()` or print all the active link elements. Any technique is good if it helps you get to the bottom of the issue faster.
129+
> This change won't affect the class name condition but instead will expose the `item` variable to this closure.
137130

138-
To teach you more tools for debugging, I will approach this issue differently. If I take a look above the printed error in the terminal, I see this line:
131+
Now, I can add a conditional breakpoint in Visual Studio Code by right-clicking on the gutter next to the line I need and choosing the "Add Conditional Breakpoint..." option from the context menu:
139132

140-
```
141-
Failure screenshot:
142-
- src/__screenshots__/main-menu.browser.test.tsx/----1.png
143-
```
133+
![A screenshot of Visual Studio Code with the context menu open when right-clicked on the gutter. The "Add Conditional Breakpoint..." option is highlighted.](/assets/04-03-conditional-breakpoint-context-menu.png)
144134

145-
Vitest takes screenshots of the page for every failed test case to give you a visual clue about what is going on. Let's take a look at that screenshot:
135+
![A screenshot of Visual Studio Code with the Conditional breakpoint context menu open. The condition is entered to match the item with title "Dashboard".](/assets/04-03-conditional-breakpoint-add.png)
146136

147-
![A screenshot of the page with the main menu component rendered. It shows two menu links as active—dashboard and analytics.](/assets/04-03-test-screenshot.png)
137+
Let's run the `main-menu.browser.test.tsx` test suite with the debugger attached and arrive at this breakpoint just when I mean to:
148138

149-
Aha! Both "Dashboard" and "Analytics" are treated as currently active navigation links.
139+
![A screenshot of Visual Studio Code with the debugger stopped exactly at the conditional breakpoint](/assets/04-03-conditional-breakpoint-stop.png)
150140

151-
> 🦉 While it's not forbidden to have multiple `aria-current="page"` elements at the same time, it is generally advised by the [WAI-ARIA specification](https://w3c.github.io/aria/#aria-current) to "only mark **one** element as current".
141+
As usual, I can look around to see the `item` and `isActive` values in this scope. Just like I suspected, the `isActive` prop is set to `true` for the Dashboard menu item. But why?
152142

153-
The fact that the "Analytics" menu item is marked as current is correct, that's what I expect. It's the "Dashboard" that's likely wrong here. It would be great to stop the time and look around when that menu item is getting rendered.
143+
The answer to that lies deeper down the stack trace. If I click on the `NavLinkWithRef` frame in the stack trace, the debugger will bring me to the rendering part of `NavLink` from my menu. Here, I can inspect all the props that the `NavLink` gets but also _gain access to everything in its scope_.
154144

155-
That's a great use case for a breakpoint!
145+
I am particularly interested in this line, where the `isActive` variable gets assigned:
156146

157-
But if I add a breakpoint in `<MenuItemsList />`, it will trigger on _every menu item_ that gets rendered. This will force me to step through irrelevant items before I get to the one I want, wasting my time.
147+
```ts nonumber
148+
let isActive =
149+
locationPathname === toPathname ||
150+
(!end &&
151+
locationPathname.startsWith(toPathname) &&
152+
locationPathname.charAt(endSlashPosition) === '/')
153+
```
154+
155+
I can still see that it's `false`, but having access to all the data React Router uses to compute this value, I can play around with it and, hopefully, find the root cause.
158156

159-
Instead, I will use a _conditional breakpoint_. It's like a regular breakpoint but it will only "activate" when a certain condition is met. What's great about these kind of breakpoints is that I can use expressions, accessing anything within the scope to write my condition.
157+
I will start from inspecting the `locationPathname` and `toPathname` values in the Debug console:
160158

161-
![A screenshot of Visual Studio Code. The context menu of the gutter is open and the "Add Conditional Breakpoint" menu item is selected](/assets/04-03-conditional-breakpoint-01.png)
159+
![A screenshot of Visual Studio Code with the Debug console panel open. Inside, two variables are printed out. The first one is "locationPathname" with the value "/dashboard/analytics". The second one is "toPathname" with the value "/dashboard".](/assets/04-03-debug-console-nav-link.png)
162160

163-
> I am putting the breakpoint on the line with `render` to trigger before the problematic item is rendered.
161+
<callout-success>Debug console is your go-to choice for evaluating expressions inside the breakpoints. You can reference everything available in the current scope, call functions, declare new variables, etc. This is your _debugging sandbox_!</callout-success>
164162

165-
As the condition, I will tell this breakpoint to become active only when the rendered item's `title` equals to `'Dashboard'`:
163+
Since `locationPathname` (document location) is `/dashboard/analytics` just like I set it in tests, it certainly doesn't equal the link's `to` path, which is `/dashboard`. The condition follows on, and now it branches based on the `end` prop. Once I take a peek at its value...
166164

167-
![A screenshot of Visual Studio Code. The condition popup is open, and the expression is set to activate this breakpoint when the item's title equals to dashboard](/assets/04-03-conditional-breakpoint-02.png)
165+
![A screenshot of Visual Studio Code with the Debug console panel open. Inside, the "end" variable is printed out, and its value is false.](/assets/04-03-debug-console-end.png)
168166

169-
If I run this test suite using the "Debug Vitest Browser" task we've prepared earlier, I will see the code execution stop right before the Dashboard menu item is about to get rendered:
167+
It's `false`! This means that the first logical branch will resolve (the one with `!end`), and return `true` as the `isActive` value because _current location starts from the link's path_:
170168

171-
![A screenshot of Visual Studio Code. The debugger is running and the code execution is stopped at the return line with the conditional breakpoint. To the right, the debug panel is showing the item, isActive, and location variables observable in the current scope.](/assets/04-03-conditional-breakpoint-03.png)
169+
```ts nonumber
170+
'/dashboard/analytics'.startsWith('/dashboard')
171+
// true
172+
```
172173

173-
There are three key figures to observe in the "Variables" panel:
174+
🤦 I forgot to pass `end={true}` to the `NavLink` in my menu. Luckily, that's an easy fix:
175+
176+
```tsx filename=main-menu.tsx nonumber nocopy add=3
177+
<NavLink
178+
to={item.url}
179+
end={true}
180+
className={({ isActive }) =>
181+
[
182+
'px-2 py-1 hover:text-blue-600 hover:underline',
183+
isActive ? 'font-bold text-black' : 'text-gray-600',
184+
].join(' ')
185+
}
186+
>
187+
```
174188

175-
1. The currently rendered `item` is, indeed, the Dashboard item (`item.title` and `item.url` confirm that);
176-
1. The `location` object from the parent closure has our current route set correctly by the test (`location.pathname` is `/dashboard/analytics`);
177-
1. However, the `isActive` object (the result of `matchPath()`) is _present_ for the Dashboard menu item where it shouldn't be.
189+
> The `end` prop (also `exact` sometimes) forces the link to be active only if its path and the document path are identical.
178190

179-
It looks like `/dashboard` pathname matches `/dashboard/analytics`! 🤦
191+
With this change, the test suite is green again! 🎉
180192

181-
The culprit has been right there all along. The path matching was not strict:
193+
```sh nonumber
194+
✓ chromium src/main-menu.browser.test.tsx (1 test) 12ms
195+
✓ renders the currently active menu link
182196

183-
```tsx filename=main-menu.tsx remove=4-5 add=6-7
184-
const isActive = matchPath(
185-
{
186-
path: item.url,
187-
// This makes path matching loose.
188-
end: false,
189-
// This makes path maching exact.
190-
end: true,
191-
},
192-
location.pathname,
193-
)
197+
Test Files 1 passed (1)
198+
Tests 1 passed (1)
194199
```
195200

196201
Now you know how to harness the power of conditional breakpoints to debug your React components or any JavaScript code in general. Use it wisely!

Diff for: exercises/04.debugging/03.solution.breakpoints/src/main-menu.tsx

+9-14
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { matchPath, NavLink, useLocation } from 'react-router'
1+
import { NavLink } from 'react-router'
22

33
interface MenuItem {
44
title: string
@@ -31,26 +31,21 @@ const menuItems: Array<MenuItem> = [
3131
},
3232
] as const
3333

34-
function MenuItemsList(props: { items: Array<MenuItem> }) {
35-
const location = useLocation()
36-
34+
function MenuItemsList({ items }: { items: Array<MenuItem> }) {
3735
return (
3836
<ul className="ml-4">
39-
{props.items.map((item) => {
40-
const isActive = matchPath(
41-
{ path: item.url, end: true },
42-
location.pathname,
43-
)
44-
37+
{items.map((item) => {
4538
return (
4639
<li key={item.url}>
4740
<NavLink
4841
to={item.url}
49-
className={[
50-
'px-2 py-1 hover:text-blue-600 hover:underline',
51-
isActive ? 'font-bold text-black' : 'text-gray-600',
52-
].join(' ')}
5342
end={true}
43+
className={({ isActive }) =>
44+
[
45+
'px-2 py-1 hover:text-blue-600 hover:underline',
46+
isActive ? 'font-bold text-black' : 'text-gray-600',
47+
].join(' ')
48+
}
5449
>
5550
{item.title}
5651
</NavLink>

Diff for: public/assets/04-03-conditional-breakpoint-add.png

647 KB
Loading
686 KB
Loading

Diff for: public/assets/04-03-conditional-breakpoint-stop.png

800 KB
Loading

Diff for: public/assets/04-03-debug-console-end.png

806 KB
Loading

Diff for: public/assets/04-03-debug-console-nav-link.png

826 KB
Loading

Diff for: public/assets/04-04-debug-console-01.png

-612 KB
Binary file not shown.

Diff for: public/assets/04-04-debug-console-02.png

-631 KB
Binary file not shown.

Diff for: public/assets/04-04-debug-console-example.png

-651 KB
Binary file not shown.

0 commit comments

Comments
 (0)