JavaScript Debugging with the Sources Panel: Breakpoint Types, Source Maps and the Ignore List
In the Sources panel you find a bug with proof instead of guesses: line, conditional and logpoint breakpoints, DOM, XHR/fetch and exception breakpoints, the async call stack and Scope/Watch. Step by step through an Angular 22 signal effect that never fires because an array was mutated in place; Angular CLI source maps, hidden source maps, hiding framework frames with x_google_ignoreList, and Workspaces.
Version note: The examples in this post were run with Chrome 156 (156.0.8078.80), Angular 22.2, Angular CLI (Command Line Interface) 22.2.6 and .NET 10. Breakpoint, call stack and scope outputs were captured from the same Chrome version in headless mode through the Chrome DevTools Protocol (CDP) and translated back to the original TypeScript lines with source maps; panel steps come from the Chrome DevTools documentation.
Predict First
In Bookstore, the cart lives in a root service as a signal. The badge in the top menu shows the item count and the total, and an effect writes the cart to localStorage on every change:
@Injectable({ providedIn: 'root' })
export class CartStore {
readonly items = signal<CartItem[]>([]);
readonly total = computed(() => this.items().reduce((sum, i) => sum + i.price * i.quantity, 0));
constructor() {
effect(() => {
const items = this.items();
localStorage.setItem('bookstore.cart', JSON.stringify(items));
console.log('cart saved', items.length);
});
}
add(book: Book): void {
this.items.update(list => {
list.push({ bookId: book.id, title: book.title, price: book.price, quantity: 1 });
return list;
});
}
}
We clicked "Add to cart" 25 times on the book list. The badge text and the console logs:
initial badge: 0 · $0.00 | logs: ["cart saved 0"]
after 25 clicks badge: 25 · $0.00 | cart saved logs: ["cart saved 0"]
The count is 25, the total is still $0.00, the effect ran only once at startup, and localStorage holds an empty array. Of three things fed by the same signal, one updates and two do not. Why do you think that is? You may already know the answer; but in this post we will find it in the Sources panel by proving it step by step rather than by guessing. The next bug will not be this familiar.
The Sources Panel and Angular's Source Maps
The Sources panel has tabs on the left: Page shows everything the page loaded, grouped by origin; Workspace is where you connect a local folder; Overrides is where you override responses; Snippets holds your own small scripts. The editor is in the middle, and the debugger sections (Breakpoints, Scope, Call Stack, Watch) are on the right. Open a file with Ctrl+P (Cmd+P on a Mac), search all files with Ctrl+Shift+F (Cmd+Option+F), and go to a line with Ctrl+G.
Type cart.store into Ctrl+P and you get the TypeScript file, not the compiled JavaScript. Source maps make that possible. ng serve runs the development configuration with sourceMap: true and embeds the map at the end of the script as a data URL (Uniform Resource Locator). Reading the maps of the scripts the page loaded from CDP's Debugger.scriptParsed event, all application code was in a single main.js:
/main.js sources=15 ignoreList=0
.../vite/deps/core-CP38VavK.js sources=5 ignoreList=5
.../vite/deps/esm5-Doa4YBQt.js sources=222 ignoreList=222
.../vite/deps/@angular_router.js sources=3 ignoreList=3
The 15 sources of main.js are your files, such as src/main.ts and src/app/cart.store.ts. Angular, RxJS and tslib live in separate chunks, and in each of their maps every source is in ignoreList. We will come back to that list shortly. You put the breakpoint on a TypeScript line and DevTools translates it through the map to the position in the compiled code; in our test, cart.store.ts:19 landed on main.js:269.
Production is different. The default production configuration has no source maps. If you want maps for debugging but do not want to show them to users, turn on hidden source maps in angular.json:
"production": {
"sourceMap": { "scripts": true, "styles": false, "hidden": true, "vendor": false },
"outputHashing": "all"
}
The build output got a .map file next to each script, but the last line of main-X24X3BNP.js has no sourceMappingURL comment:
main-X24X3BNP.js.map
chunk-XEUCJQTH.js.map
chunk-3Y4P6I6V.js.map
...;ri(Tr,pa).catch(t=>console.error(t));
//# debugId=37424c98-4b75-59f3-9fae-bccd5b16ba28
The browser cannot find the map on its own; you do not put the map files on the server, you upload them to your error tracking tool. When you need to inspect a live error in DevTools, right-click the compiled file in the Sources editor and use Add source map to give the map's address by hand. In this build's main map, 12 of 34 sources were in ignoreList, and in the framework chunk 89 of 97.
Line Breakpoints, Scope and Watch: Proving the Bug
Our hypothesis: the effect and the computed do not run, so the signal is not saying "I changed". We open cart.store.ts and click the line number of return list; (or press Ctrl+B with the cursor on the line). We click the button and the debugger pauses. Call Stack and Scope:
line breakpoint cart.store.ts:20: reason=other
(anonymous) (src/app/cart.store.ts:20)
add (src/app/cart.store.ts:18)
BookList_For_4_Template_app_book_card_addToCart_0_listener (src/app/book-list/book-list.ts:16)
BookCard_Template_button_click_8_listener (src/app/book-card/book-card.ts:14)
(8 frames from ignore-listed sources hidden)
Local scope: list=Array(1)
Read the call stack bottom up: the click listener of the button in BookCard's template, the (addToCart) listener in BookList's template, CartStore.add, and the arrow function we passed to update. The template listener names are functions generated by the Angular compiler, but the source map ties them to the right TypeScript lines. The Angular frames in between are hidden thanks to the ignore list.
Now the proof. We right-click list in Scope and choose Store as global variable; it becomes temp1. We click the add frame in Call Stack; the console now runs in that frame's context. The real values:
frame (anonymous): list.length = 1
frames between (anonymous) and add: signalUpdateFn, update
frame add: this.items().length = 1
temp1 (list) === this.items() in frame add = true
Before update has even returned, the signal's own value already has one element, and it is the same reference as list. So we mutated the array the signal was holding in place, then handed the same array back as the "new value". Signals compare with Object.is by default; since old and new values are the same reference, the signal reports no change. With no notification, the total computed keeps returning its cached 0, and the effect never runs again.
So why is the count right? Clicking marks that view and its ancestors dirty through Angular's template listener. On the next change detection, the App template re-reads cart.items().length and sees the length of the mutated array. The count updates as a side effect of the click, not because of the signal. This is exactly a "sometimes it works" bug: had the item been added after an HTTP (HyperText Transfer Protocol) (HyperText Transfer Protocol) response, the count would have frozen too.
Add expressions such as list.length or book.title to the Watch section and they are re-evaluated at every pause. Looking at Watch, at Scope, or at the popover that opens when you hover over a variable in the editor is the real payoff of pausing: you see the current state without adding console.log or waiting for a rebuild.
Conditional Breakpoints and Logpoints
If you do not want to pause on every click, right-click the line number and choose Add conditional breakpoint (or click the line number with Ctrl/Cmd). We put the condition book.price > 40 on the list.push line, clicked 24 books, and the debugger paused only five times:
conditional breakpoint (book.price > 40) paused for: "21:41", "22:42", "23:43", "24:44", "25:45"
A logpoint is a breakpoint that never pauses: choose Add logpoint from the same menu, type an expression such as 'add', book.id, 'length', list.length, and every time the line runs it lands in the console. The first three lines of the real output and the total:
[logpoint] add 2 length 2
[logpoint] add 3 length 3
[logpoint] add 4 length 4
total logpoint lines: 24
A logpoint does not change code, does not sneak a console.log into a commit, does not wait for a rebuild, and disappears when DevTools closes. For the plain question "does this line run, how often, with which value", use it instead of changing code. Each breakpoint in the Breakpoints section has a checkbox, so you can disable it temporarily without deleting it. Deactivate breakpoints in the toolbar (Ctrl+F8) suspends all of them at once.
Stepping and the Async Call Stack
There are five commands to move on from where you paused: Resume (F8 or Ctrl+\), Step over (F10), Step into (F11), Step out (Shift+F11) and Step (F9). Step into on an async function call enters the function; you reach the code that continues after an await by stepping as well. Right-click a line number and choose Continue to here to pause there as if with a temporary breakpoint.
The strongest side of the Call Stack is async frames. In a zoneless Angular app, Angular's scheduler plans change detection. When the badge's DOM (Document Object Model) changed (with the DOM breakpoint below), the paused call stack looked like this:
DOM breakpoint .cart-badge subtree-modified: reason=DOM
App_Template (src/app/app.ts:12)
-- async: requestAnimationFrame
(30 frames from ignore-listed sources hidden)
The only frame from your code in the synchronous part is App_Template; the rest is Angular's render loop. The -- async: requestAnimationFrame line below it is the important part: this render ran in a requestAnimationFrame callback. DevTools collects async call stacks by default; in CDP, Debugger.setAsyncCallStackDepth controls it. This ordering of the event loop is covered in the Advanced JavaScript series post "The JavaScript Event Loop in Depth: Tasks, Microtasks and Rendering Order".
Breaking on Events Instead of Lines: DOM, XHR/fetch, Event Listener and Exception
Sometimes you do not know which line to put a breakpoint on. For "who is changing this element", right-click the node in the Elements panel and choose subtree modifications, attribute modifications or node removal under Break on. We set subtree modifications on the badge and got the call stack above: the change is made by the template on line 12 of app.ts.
For "who is sending this request", add part of a URL to the XHR (XMLHttpRequest)/fetch Breakpoints section in Sources. We added /api/books/ and clicked a book's detail link:
XHR/fetch breakpoint "/api/books/": reason=XHR
ngOnInit (src/app/book-detail/book-detail.ts:27)
-- async: requestAnimationFrame
(72 frames from ignore-listed sources hidden)
With the ignore list on, a 72-frame chain of HttpClient, RxJS and the router collapses into one line: the request was started by BookDetail.ngOnInit. Without the ignore list you would have to scroll to find your own line in this stack.
The Event Listener Breakpoints section lists events by category (such as Mouse > click and Keyboard > keydown); it pauses just before any listener of the checked event runs. The two boxes above the Breakpoints section are for exceptions: Pause on uncaught exceptions pauses on unhandled errors, and Pause on caught exceptions pauses even on errors swallowed inside try/catch. The latter is the cure for the case where a service catches an error and quietly returns an empty list.
The Ignore List: Skipping Framework Code
The "hidden" counts in the outputs above are not random. Angular CLI puts an x_google_ignoreList field in the source maps it generates: the indexes of sources coming from node_modules are in this list. The "Automatically add known third-party scripts to ignore list" option on Chrome's Settings > Ignore list tab reads this field, and, as the documentation notes, Angular is one of the frameworks that provide it. On the same tab you can add your own regex as a custom exclusion rule; the debugger already skips /node_modules/ and /bower_components/ by default.
Ignore-listed scripts are hidden in the Call Stack (you reveal them with "Show ignore-listed frames"), and step into does not enter them; stepping into update takes you straight to your arrow function instead of Angular's signalUpdateFn. If you suspect a framework bug, right-click the frame to take the script off the list, and put it back when you are done.
The Fix and Saving It: Workspaces, Snippets and Local Overrides
The fix is one line: return a new array instead of mutating it in place.
add(book: Book): void {
this.items.update(list => [...list, { bookId: book.id, title: book.title, price: book.price, quantity: 1 }]);
}
FIXED after 3 clicks badge: 3 · $66.00 | logs: [..., "cart saved 1", "cart saved 2", "cart saved 3"]
The total is now right (21 + 22 + 23 = 66), and the effect runs on every add. Why immutable updates are a must with signals is covered in the Angular series post "What's New in Angular 16: Signals, Required Inputs and takeUntilDestroyed".
If you want to make this fix inside DevTools and save it straight to the file, there are Workspaces. The Angular dev server makes this easy: ng serve serves the project's root path and a UUID (Universally Unique Identifier) at /.well-known/appspecific/com.chrome.devtools.json:
{
"workspace": {
"root": ".../devtoolswork_01-10/bookstore-web",
"uuid": "09b65a18-b614-462d-af5d-5b989bd00a2a"
Click Connect in Sources > Workspace and grant permission to edit files, and the changes you make in Sources are written to the file on disk, where the dev server picks them up and recompiles. According to the documentation, DOM changes in the Elements panel are not saved; make permanent changes in Sources.
Snippets are small scripts you keep under Sources > Snippets and run on the page with Ctrl+Enter; you can also run one from the Command Menu by typing ! and its name. They are a good home for repeated debugging steps such as "fill the cart with 20 items" or "dump all card prices into a table". Local Overrides override the response from the server, not your source code; we will use them in the next post to mock an API (Application Programming Interface).
Production Notes
Do not put hidden source maps on the production server. If the file is reachable, anyone who guesses the name main-X24X3BNP.js.map can read your source code, even without a sourceMappingURL. Upload the maps to your error tracking tool in the build pipeline and strip them from the artifact.
A production build is a minified world without an ignore list: unless you load the map, the call stack fills with names like ri and Tr. So try to reproduce a live error in a development build first; if that fails, inspect it in Sources by adding the map by hand.
A page paused on a breakpoint freezes everything: timers, animations and pending requests. In a timing-sensitive bug (debounce, timeout, WebSocket heartbeat), pausing can change the behavior; there a logpoint is the more honest observation tool.
Common Pitfalls
- Putting the breakpoint in the compiled
main.js: if the TypeScript file does not open, check whether the source map loaded. - Looking at
thisin an arrow function frame: in our testthiswasundefinedthere; for the class's state, select the method's frame in the Call Stack. - Leaving Pause on caught exceptions on and pausing on every exception inside the framework.
- Assuming the signal works because the count is right: a template re-read is no substitute for a signal notification.
- Turning off the ignore list and getting lost inside the framework; or the opposite, suspecting the framework while the list hides the relevant frame.
- Deploying hidden source maps together with the
distfolder.
A Small Experiment
★☆☆ Bring back the buggy CartStore, put a breakpoint on the return list; line, and when it pauses turn on "Show ignore-listed frames" in the Call Stack. Read the names of the hidden frames, then turn it off again.
★★☆ Put a logpoint on the list.push line, then add a conditional breakpoint with book.stock === 0 on the same line. Confirm it pauses when an out-of-stock book is added to the cart, and add book.title and list.length to Watch.
★★★ Add a catchError to BookService.getBooks that returns an empty array on error, stop the API, and open the list. The page is empty but the console shows no error. Use Pause on caught exceptions to find where the error is swallowed, then confirm with an XHR/fetch breakpoint which component started the request.
The XHR/fetch breakpoint showed who sent the request; but the request itself, its headers, how long it waited and why it is slow are the Network panel's job. In the next post we cover the waterfall, timing phases, throttling, HAR (HTTP Archive) files and mocking an API with Local Overrides while the backend is not ready.