Persist the selected marker in the url#5847
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #5847 +/- ##
==========================================
+ Coverage 85.56% 85.57% +0.01%
==========================================
Files 319 319
Lines 31420 31560 +140
Branches 8661 8711 +50
==========================================
+ Hits 26885 27009 +124
- Misses 4104 4120 +16
Partials 431 431 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
canova
left a comment
There was a problem hiding this comment.
Thanks for the PR!
I would prefer to merge the 2 dispatches inside finalizeFullProfileView, see my comment below.
I noticed that when you scroll down the marker chart and select the marker, it doesn't scroll enough to keep the marker in the viewport. For example:
https://deploy-preview-5847--perf-html.netlify.app/public/707b9xarf5kkjg49pn1rrgs8fg20mrz554wdc1g/marker-chart/?globalTrackOrder=fwi0we&marker=46160&thread=0&v=14
Can you look into that?
I couldn't finish my full review yet (for example getTooltipPosition etc.), but sending my initial findings beforehand.
src/actions/receive-profile.ts
Outdated
| // Initialize selected markers from URL state if present | ||
| if (hasUrlInfo) { | ||
| const selectedMarkers = getSelectedMarkers(getState()); | ||
| // Dispatch marker selection for each thread that has a marker in URL | ||
| for (const [threadsKey, markerIndex] of Object.entries(selectedMarkers)) { | ||
| if (markerIndex !== null) { | ||
| dispatch(changeSelectedMarker(threadsKey, markerIndex)); | ||
| } | ||
| } | ||
| } | ||
| }; |
There was a problem hiding this comment.
Hmm, is there a reason why this is not merged with VIEW_FULL_PROFILE? Currently, we only have single dispatch inside finalizeFullProfileView and we put all the state update there to update it once. We should ideally keep it that way. We can update the relevant reducers to update the state on VIEW_FULL_PROFILE. How does the transform url param handles this?
There was a problem hiding this comment.
Yes, what you say is 100% valid, I kinda missed that logic. I've made a commit now for this specific piece of feedback - could you pls have a quick look if I got you correctly?
| override componentDidMount() { | ||
| // Initialize selectedItem from props on mount if provided | ||
| // Use requestAnimationFrame to ensure the canvas is fully laid out | ||
| if (this.props.selectedItem !== undefined) { |
There was a problem hiding this comment.
Nit: If you assign selectedItem to a const variable, then you won't have to assert below with !.
For example:
const { hoveredItem } = this.props;
if (selectedItem !== undefined) {
...
this._syncSelectedItemFromProp(selectedItem);It's more accurate type-wise because typescript now knows that it can't be changed in between componentDidMount and requestAnimationFrame callback.
But apart from that, I don't think window.requestAnimationFrame is great here. Can you tell me why it was needed? Maybe we can find a different way.
There was a problem hiding this comment.
Regarding the nit:
It is usually true, yes, but not here, because there are different scopes accessing this.props.selectedItem. One of them attaches the callback, another access is inside the callback itself. So, removing the exclamation mark will yield TS2345: Argument of type 'Item | null | undefined' is not assignable to parameter of type 'Item | null'. Type 'undefined' is not assignable to type 'Item | null'.
Now regarding the usage of window.requestAnimationFrame: to be honest, I did not have it in the first place too, but I've asked claude to review my code before submitting. The feedback from claude was the following:
The
requestAnimationFrameis necessary here. Inside_syncSelectedItemFromProp, we callthis._canvas.getBoundingClientRect():const canvasRect = this._canvas.getBoundingClientRect(); const pageX = canvasRect.left + window.scrollX + tooltipPosition.offsetX; const pageY = canvasRect.top + window.scrollY + tooltipPosition.offsetY;
componentDidMountfires right after the component's DOM nodes are inserted into the document, but before the browser has performed layout. At that moment, the canvas exists in the DOM but may not yet have its final size and position calculated —getBoundingClientRect()could return zeros or stale values.
requestAnimationFramedelays the call until just before the next paint, by which point the browser has done a layout pass andgetBoundingClientRect()returns accurate values.Without it, on initial load the tooltip would likely appear at position (0, 0) or some incorrect location because the canvas's bounding rect hasn't been computed yet.
Why this only affects
componentDidMountand notcomponentDidUpdate: by the timecomponentDidUpdateruns, the component has already been rendered at least once and layout has occurred, sogetBoundingClientRect()is already reliable.
It seemed as valid feedback to me. But if you don't agree - let's discuss.
There was a problem hiding this comment.
It is usually true, yes, but not here, because there are different scopes accessing
this.props.selectedItem. One of them attaches the callback, another access is inside the callback itself. So, removing the exclamation mark will yieldTS2345: Argument of type 'Item | null | undefined' is not assignable to parameter of type 'Item | null'. Type 'undefined' is not assignable to type 'Item | null'.
That's why I suggested to assign the selectedItem to another constant variable. (ah now I see that in my example I used hoveredItem instead of selectedItem. So the full code would be:
const { selectedItem } = this.props;
if (selectedItem !== undefined) {
window.requestAnimationFrame(() => {
this._syncSelectedItemFromProp(selectedItem);
});
}And thinking about it, this callback has a bug because of it. First we check this.props.selectedItem to see if it's not undefined. Then we use it inside this callback, what happens if the value of it is changed in between?
For the rAF, there is really no guarantee that the Viewport update will happen in the next rAF callback. So it looks a bit racy to me.
So I would probably:
- In componentDidMount: Call _syncSelectedItemFromProp directly, but only if
containerWidth !== 0. If it's still 0, skip it, componentDidUpdate will pick it up. - In componentDidUpdate: Add a guard for when prevProps.containerWidth === 0 && this.props.containerWidth !== 0 && this.props.selectedItem !== undefined.
This will remove the rAF and it will make sure that the sync happens after the layout. But I haven't tested it fully. Let me know if you think otherwise!
There was a problem hiding this comment.
You're right, I've rewritten this piece of code.
src/app-logic/url-handling.ts
Outdated
| } | ||
|
|
||
| // Parse the selected marker for the current thread | ||
| const selectedMarkers: { [key: string]: MarkerIndex | null } = {}; |
There was a problem hiding this comment.
Nit: We can use the SelectedMarkersPerThread type here directly.
14671c0 to
fff55d5
Compare
|
@canova Thank you very much for the valuable feedback! I think I went thru all the points you've raised and now this PR should be ready for another review :) |
323de83 to
2990331
Compare
There was a problem hiding this comment.
Nice, thanks a lot for working on it! It looks pretty good now. I had some comments below.
I like that the tooltip now follows the marker properly when we scroll!
I noticed that the initial scroll is not always working 100%. For example, see this link. I selected the "FirstContentfulComposite" marker and I can't really see the marker in the viewport. I'm not 100% sure why it's happening yet. Could you look into it?
| import { StringTable } from 'firefox-profiler/utils/string-table'; | ||
|
|
||
| export const CURRENT_URL_VERSION = 13; | ||
| export const CURRENT_URL_VERSION = 14; |
There was a problem hiding this comment.
Thanks for updating the url version! It's good to do it.
| query = baseQuery as MarkersQueryShape; | ||
| query.markerSearch = | ||
| urlState.profileSpecific.markersSearchString || undefined; | ||
| query.marker = |
There was a problem hiding this comment.
Can we maybe add a TODO for supporting network-chart in the future too
| ) { | ||
| const markerIndex = Number(query.marker); | ||
| if (!isNaN(markerIndex)) { | ||
| selectedMarkers[selectedThreadsKey] = markerIndex; |
There was a problem hiding this comment.
I wish there was a way to further validate if the marker index is valid at this stage, but I couldn't find a way.
But maybe we can have a bit better check for the number. Can you do maybe Number.isInteger(markerIndex) && markerIndex >= 0?
src/selectors/per-thread/markers.ts
Outdated
| */ | ||
| const getSelectedMarkerIndex: Selector<MarkerIndex | null> = (state) => | ||
| threadSelectors.getViewOptions(state).selectedMarker; | ||
| UrlState.getSelectedMarker(state, threadsKey); |
There was a problem hiding this comment.
Can we now do further validation here? Since we can get the markerList with getFullMarkerList now, we can add a check like this before returning the url state:
if (markerIndex === null || markerIndex >= markerList.length) {
return null;
}| const markerIndexToTimingRow = this._getMarkerIndexToTimingRow( | ||
| markerTimingAndBuckets | ||
| ); | ||
| const rowIndex = markerIndexToTimingRow[selectedMarkerIndex]; |
There was a problem hiding this comment.
Can we check if the rowIndex is found here? It can be undefined if we can't find it.
There was a problem hiding this comment.
It was there before, I've removed it because Uint32Array (result of this._getMarkerIndexToTimingRow) will return 0 for non-existent items, not undefined. But there does not seem to be anything bad with it. If the selected marker is on a thread that was for example filtered out, we will just scroll to the very top on load.
There was a problem hiding this comment.
It will still return undefined if the index is out of bounds, for example: new Uint32Array(4)[5]
There was a problem hiding this comment.
I understood why I had that impression. Without having noUncheckedIndexedAccess: true in tsconfig (which we don't have), ts language server and thus my IDE infers the type as number only, assuming a successful array lookup. But adding that flag seems to bring many errors, so I will just take that into account without adding any flags.
| // Current vertical scroll offset of the viewport. | ||
| readonly viewportTop?: CssPixels; |
There was a problem hiding this comment.
Hmm, this looks a bit brittle. I don't think we should get the viewportTop from the parent component since it's actually an internal detail of the canvas.
Maybe we can replace both getTooltipPosition and viewportTop with something like this:
// Pre-computed canvas-relative offset for the tooltip of the selected item.
readonly selectedItemTooltipOffset?: { offsetX: CssPixels; offsetY: CssPixels } | null;
And we always handle the selectedItemTooltipOffset calculation in the render of the parent component.
We can then use this value directly inside _syncSelectedItemFromProp. I think this might simplify a few things.
What do you think?
There was a problem hiding this comment.
This indeed sounds better. I'll re-implement it that way, thanks for your suggestion.
| // The canvas just received its dimensions for the first time (containerWidth | ||
| // went from 0 to non-zero). If a selectedItem was provided but couldn't be | ||
| // synced in componentDidMount, do it now. | ||
| if ( | ||
| prevProps.containerWidth === 0 && | ||
| this.props.containerWidth !== 0 && | ||
| this.props.selectedItem !== undefined | ||
| ) { | ||
| this._syncSelectedItemFromProp(this.props.selectedItem); | ||
| } | ||
|
|
||
| // The viewport scrolled (e.g. to bring the selected item into view on | ||
| // load). Re-sync the tooltip position so it stays on the selected item. | ||
| if ( | ||
| this.props.viewportTop !== undefined && | ||
| this.props.viewportTop !== prevProps.viewportTop && | ||
| this.props.selectedItem !== undefined | ||
| ) { | ||
| this._syncSelectedItemFromProp(this.props.selectedItem); | ||
| } | ||
|
|
There was a problem hiding this comment.
If we go with the selectedItemTooltipOffset solution, I would merge these both branches into one:
// Re-sync the tooltip position when the offset changes — this covers both
// the initial viewport sizing (null → valid) and subsequent scrolls that
// move the selected item. Only trigger when the item itself hasn't changed;
// a changed item is already handled by the condition above.
if (
this.props.selectedItemTooltipOffset !==
prevProps.selectedItemTooltipOffset &&
this.props.selectedItem !== undefined &&
this.props.selectedItem === prevProps.selectedItem
) {
this._syncSelectedItemFromProp(this.props.selectedItem ?? null);
}1259ed7 to
c4152c4
Compare
…ectedItemTooltipOffset Replace the getTooltipPosition callback and viewportTop props with a single pre-computed selectedItemTooltipOffset prop. The parent component (MarkerChartCanvas) now owns the full tooltip position calculation including the viewport bounds check, since it has direct access to all the layout data. This simplifies ChartCanvas by removing internal details it didn't need to know about: - _syncSelectedItemFromProp no longer calls a callback or checks viewport bounds; - The three separate componentDidUpdate trigger conditions collapse into one; - viewportTop is no longer leaked as a prop. Also fix scroll-to-selected-marker on load when the viewport resizes after the initial layout (e.g. flex containers settling), by re-running _scrollSelectionIntoView when containerHeight changes.
Merging 'main' into this branch introduced an additional `componentDidMount`. This commit merges them.
canova
left a comment
There was a problem hiding this comment.
Looks good to me, thanks!
One thing I noticed was that when we select a long duration marker and then scroll, the tooltip shifts position vertically. But this is still an improvement to its old behavior (not moving at all on scroll), so let's land this and see if we can improve that part later.
| ); | ||
| const rowIndex = markerIndexToTimingRow[selectedMarkerIndex]; | ||
|
|
||
| if (rowIndex === undefined) return; |
There was a problem hiding this comment.
Nit: We always wrap the if branches with curly braces in this repo in general. It would be good to be consistent. Could you wrap this return statement as well?
(I thought we had a linter error for this, but maybe that went away over the time. I see a few of them slipped in the codebase. It might be good to revisit that later at some point)
| const offsetX = isInstantMarker ? x : x + Math.min(w / 3, 30); | ||
|
|
||
| // Step 5: Calculate vertical (Y) position | ||
| const offsetY: CssPixels = rowIndex * rowHeight - viewportTop + 5; |
There was a problem hiding this comment.
Hmm, what's + 5? Can you add a comment also
There was a problem hiding this comment.
rowIndex * rowHeight - viewportTop gives the exact top of the row in canvas-relative coordinates; + 5 shifts it down a few pixels into the row body. I'll add a comment.
| this.props.selectedItemTooltipOffset !== | ||
| prevProps.selectedItemTooltipOffset; | ||
|
|
||
| const alreadySetInternally = |
There was a problem hiding this comment.
Hmm, what's alreadySetInternally? Could be good to add a comment.
1. Use curly braces with conditionals 2. Add necessary comments
Thanks a lot for the review! Regarding this, could you please record a short video with what exactly you mean? |
|
@canova Once again, thanks for the elaborate review! I have addressed your comments, merging now 😌 |
* main: (289 commits) Escape CSS URLs that are coming from profiles (firefox-devtools#5874) Memoize the computation of search filtering across threads. Memoize the computation of the implementation-filtered thread across threads. Memoize the computation of the call node table across threads. Reserve functions for collapsed resources globally. Correctly adjust transforms when merging profiles. Use Int32Array and oldXToNewXPlusOne convention in merge-compare.ts. Make profile compacting apply to the newly-shared tables. Update snapshots. Share the stackTable, frameTable, funcTable, resourceTable and nativeSymbols across threads. Update docsify to v4.13.1 with a PR manually patched Remove unused ga docsify plugin Remove async attribute from module script tag. (firefox-devtools#5870) Bump rollup from 2.79.2 to 2.80.0 Remove unused defaultCategory parameter. Implement the "collapse resource" transform with the help of the "collapse direct recursion" transform. Persist selected marker in URL and show sticky tooltip on load (firefox-devtools#5847) Fix unhandled promise rejection in setupInitialUrlState (firefox-devtools#5864) Force canvas redraw when system theme changes (firefox-devtools#5861) Fix the color of dark mode back arrow svg (firefox-devtools#5863) ...
Changes: [fatadel] Fix crash when nativeSymbol index is out of bounds in assembly view (#5850) [depfu[bot]] Update all Yarn dependencies (2026-02-25) (#5859) [Nazım Can Altınova] Fix the color of dark mode back arrow svg (#5863) [fatadel] Force canvas redraw when system theme changes (#5861) [Nazım Can Altınova] Fix unhandled promise rejection in setupInitialUrlState (#5864) [fatadel] Persist selected marker in URL and show sticky tooltip on load (#5847) [Markus Stange] Implement the "collapse resource" transform with the help of the "collapse direct recursion" transform. (#5824) [Markus Stange] Bump rollup from 2.79.2 to 2.80.0 (#5868) [Markus Stange] Remove async attribute from module script tag. (#5870) [Nazım Can Altınova] Update the docsify package that's used in the user documentation (#5872) [Markus Stange] Share stackTable, frameTable, funcTable, resourceTable and nativeSymbols between threads (#5482) [Nazım Can Altınova] Escape CSS URLs that are coming from profiles (#5874) [fatadel] Update home page message for the other browser case (#5866) [fatadel] Add support for ternaries in marker labels (#5857) [Markus Stange] Reduce allocations for getStackLineInfo + getStackAddressInfo (#5761) And special thanks to our localizers: de: Ger fy-NL: Fjoerfoks it: Francesco Lodolo [:flod] nl: Fjoerfoks ru: berry ru: Valery Ledovskoy zh-TW: Pin-guang Chen
Closes #5241.
This PR adds a possibility to persist the selected marker in the URL, so that, when sharing profiles, the view of the two users matches as much as possible.
Before
https://profiler.firefox.com/public/707b9xarf5kkjg49pn1rrgs8fg20mrz554wdc1g/marker-chart/?globalTrackOrder=fwi0we&thread=0&v=13
After
https://deploy-preview-5847--perf-html.netlify.app/public/707b9xarf5kkjg49pn1rrgs8fg20mrz554wdc1g/marker-chart/?globalTrackOrder=fwi0we&marker=5173&thread=0&v=14
As you may notice, now the URL has
marker=query param that is responsible for persisting the selected marker. The URL version is bumped to version 14.