From 64d9b26f30c44b5903265221b48e7f11bdfc4d2e Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sun, 31 Aug 2025 20:10:46 -0400 Subject: [PATCH 1/5] Change default symbol server to a reliost instance. --- src/app-logic/constants.ts | 2 +- src/test/__snapshots__/url-handling.test.ts.snap | 6 +++--- src/test/store/receive-profile.test.ts | 11 ++++++----- src/utils/fetch-assembly.ts | 5 ++--- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/app-logic/constants.ts b/src/app-logic/constants.ts index 21eaa55802..acc336f1e4 100644 --- a/src/app-logic/constants.ts +++ b/src/app-logic/constants.ts @@ -104,7 +104,7 @@ export const PROFILER_SERVER_ORIGIN = 'https://api.profiler.firefox.com'; // [1] https://github.com/mstange/profiler-symbol-server/ // This is the default server. -export const SYMBOL_SERVER_URL = 'https://symbolication.services.mozilla.com'; +export const SYMBOL_SERVER_URL = 'https://mozilla.symbols.samplyprofiler.com'; // See the MarkerPhase type for more information. export const INSTANT: MarkerPhase = 0; diff --git a/src/test/__snapshots__/url-handling.test.ts.snap b/src/test/__snapshots__/url-handling.test.ts.snap index bd9e77b27a..93fb601058 100644 --- a/src/test/__snapshots__/url-handling.test.ts.snap +++ b/src/test/__snapshots__/url-handling.test.ts.snap @@ -7,7 +7,7 @@ exports[`symbolServerUrl will allow an allowed https host 1`] = `Array []`; exports[`symbolServerUrl will error when switching to an allowed but non-https host 1`] = ` Array [ Array [ - "HTTPS is required for non-localhost symbol servers. Rejecting http://symbolication.services.mozilla.com/ and defaulting to https://symbolication.services.mozilla.com.", + "HTTPS is required for non-localhost symbol servers. Rejecting http://symbolication.services.mozilla.com/ and defaulting to https://mozilla.symbols.samplyprofiler.com.", ], ] `; @@ -15,7 +15,7 @@ Array [ exports[`symbolServerUrl will error when switching to an invalid host 1`] = ` Array [ Array [ - "The symbol server URL was not valid. Rejecting invalid and defaulting to https://symbolication.services.mozilla.com.", + "The symbol server URL was not valid. Rejecting invalid and defaulting to https://mozilla.symbols.samplyprofiler.com.", [TypeError: Invalid URL: invalid], ], ] @@ -24,7 +24,7 @@ Array [ exports[`symbolServerUrl will error when switching to an unknown host 1`] = ` Array [ Array [ - "The symbol server URL was not in the list of allowed domains. Rejecting https://symbolication.services.mozilla.com.example.com/symbols and defaulting to https://symbolication.services.mozilla.com.", + "The symbol server URL was not in the list of allowed domains. Rejecting https://symbolication.services.mozilla.com.example.com/symbols and defaulting to https://mozilla.symbols.samplyprofiler.com.", ], ] `; diff --git a/src/test/store/receive-profile.test.ts b/src/test/store/receive-profile.test.ts index 39073b85ac..ae00feb7b8 100644 --- a/src/test/store/receive-profile.test.ts +++ b/src/test/store/receive-profile.test.ts @@ -77,6 +77,7 @@ import { simulateOldWebChannelAndFrameScript, simulateWebChannel, } from '../fixtures/mocks/web-channel'; +import { SYMBOL_SERVER_URL } from 'firefox-profiler/app-logic/constants'; function simulateSymbolStoreHasNoCache() { // SymbolStoreDB is a mock, but Flow doesn't know this. That's why we use @@ -805,7 +806,7 @@ describe('actions/receive-profile', function () { expect( window.fetchMock.callHistory.lastCall( - 'https://symbolication.services.mozilla.com/symbolicate/v5' + `${SYMBOL_SERVER_URL}/symbolicate/v5` )?.options ).toEqual( expect.objectContaining({ @@ -823,7 +824,7 @@ describe('actions/receive-profile', function () { expect( window.fetchMock.callHistory.lastCall( - 'https://symbolication.services.mozilla.com/symbolicate/v5' + `${SYMBOL_SERVER_URL}/symbolicate/v5` )?.options ).toEqual( expect.objectContaining({ @@ -906,7 +907,7 @@ describe('actions/receive-profile', function () { 'https://storage.googleapis.com/profile-store/FAKEHASH', unsymbolicatedProfile ) - .post('https://symbolication.services.mozilla.com/symbolicate/v5', {}); + .post(`${SYMBOL_SERVER_URL}/symbolicate/v5`, {}); simulateSymbolStoreHasNoCache(); @@ -918,7 +919,7 @@ describe('actions/receive-profile', function () { expect( window.fetchMock.callHistory.lastCall( - 'https://symbolication.services.mozilla.com/symbolicate/v5' + `${SYMBOL_SERVER_URL}/symbolicate/v5` )?.options ).toEqual( expect.objectContaining({ @@ -1234,7 +1235,7 @@ describe('actions/receive-profile', function () { expect( window.fetchMock.callHistory.lastCall( - 'https://symbolication.services.mozilla.com/symbolicate/v5' + `${SYMBOL_SERVER_URL}/symbolicate/v5` )?.options ).toEqual( expect.objectContaining({ diff --git a/src/utils/fetch-assembly.ts b/src/utils/fetch-assembly.ts index eca1606a0a..7ea92993b1 100644 --- a/src/utils/fetch-assembly.ts +++ b/src/utils/fetch-assembly.ts @@ -12,7 +12,6 @@ import type { } from 'firefox-profiler/types'; import { queryApiWithFallback } from './query-api'; import type { ExternalCommunicationDelegate } from './query-api'; -import { isLocalURL } from './url'; export type FetchAssemblyResult = | { type: 'SUCCESS'; instructions: DecodedInstruction[] } @@ -74,8 +73,8 @@ export async function fetchAssembly( // local symbol servers. Check the symbol server URL to avoid hammering the // official Mozilla symbolication server with requests it can't handle. // This check can be removed once it adds support for /asm/v1. -function _serverMightSupportAssembly(symbolServerUrl: string): boolean { - return isLocalURL(symbolServerUrl); +function _serverMightSupportAssembly(_symbolServerUrl: string): boolean { + return true; } // Convert the response from the JSON format into our own DecodedInstruction From 615092c0c2c625336d0ba7777e419aa7bca3ede1 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sat, 2 Nov 2024 21:32:46 -0400 Subject: [PATCH 2/5] Add getProfileFlowInfo selector. --- src/actions/profile-view.ts | 47 +++ src/profile-logic/marker-data.ts | 569 +++++++++++++++++++++++++++++++ src/selectors/flow.ts | 49 +++ src/selectors/index.ts | 3 + src/utils/window-console.ts | 43 +++ 5 files changed, 711 insertions(+) create mode 100644 src/selectors/flow.ts diff --git a/src/actions/profile-view.ts b/src/actions/profile-view.ts index 56cb1adc4b..3c94e1ed3f 100644 --- a/src/actions/profile-view.ts +++ b/src/actions/profile-view.ts @@ -901,6 +901,53 @@ export function showProvidedTracks( }; } +export function showProvidedThreads( + threadsToShow: Set +): ThunkAction { + return (dispatch, getState) => { + const globalTracks = getGlobalTracks(getState()); + const localTracksByPid = getLocalTracksByPid(getState()); + + const globalTracksToShow: Set = new Set(); + const localTracksByPidToShow: Map> = new Map(); + + for (const [globalTrackIndex, globalTrack] of globalTracks.entries()) { + if (globalTrack.type !== 'process') { + continue; + } + const { mainThreadIndex, pid } = globalTrack; + if (mainThreadIndex !== null && threadsToShow.has(mainThreadIndex)) { + globalTracksToShow.add(globalTrackIndex); + } + const localTracks = localTracksByPid.get(pid); + if (localTracks === undefined) { + continue; + } + + for (const [localTrackIndex, localTrack] of localTracks.entries()) { + if (localTrack.type !== 'thread') { + continue; + } + if (threadsToShow.has(localTrack.threadIndex)) { + const localTracksToShow = localTracksByPidToShow.get(pid); + if (localTracksToShow === undefined) { + localTracksByPidToShow.set(pid, new Set([localTrackIndex])); + } else { + localTracksToShow.add(localTrackIndex); + } + globalTracksToShow.add(globalTrackIndex); + } + } + } + + dispatch({ + type: 'SHOW_PROVIDED_TRACKS', + globalTracksToShow, + localTracksByPidToShow, + }); + }; +} + /** * This action makes the tracks that are provided hidden. */ diff --git a/src/profile-logic/marker-data.ts b/src/profile-logic/marker-data.ts index 3c1aa2a079..682676e10a 100644 --- a/src/profile-logic/marker-data.ts +++ b/src/profile-logic/marker-data.ts @@ -16,6 +16,7 @@ import { } from '../utils/string'; import { StringTable } from '../utils/string-table'; import { ensureExists, assertExhaustiveCheck } from '../utils/types'; +import { bisectionRightByKey } from '../utils/bisect'; import { INSTANT, INTERVAL, @@ -57,6 +58,7 @@ import type { ThreadIndex, Profile, RemoveProfileInformation, + Milliseconds, } from 'firefox-profiler/types'; /** @@ -1762,3 +1764,570 @@ export function formatLogStatement( const prefix = data.module.includes('/') ? '' : 'D/'; return `${timestampStr} - [${processName} ${pid}: ${threadName}]: ${prefix}${data.module} ${data.name.trim()}`; } + + +export type GlobalFlowMarkerHandle = { + threadIndex: number, + flowMarkerIndex: number, +}; + +// An index into the global flow table. +type IndexIntoFlowTable = number; + +export type Flow = { + id: string, + startTime: Milliseconds, + endTime: Milliseconds | null, + // All markers which mention this flow, ordered by start time. + flowMarkers: GlobalFlowMarkerHandle[], +}; + +export type ConnectedFlowInfo = { + // Flows whose marker set has a non-empty intersection with our marker set. + directlyConnectedFlows: IndexIntoFlowTable[], + // Flows which have at least one marker in their marker set was a stack-based + // marker which was already running higher up on the stack when at least one + // of our stack-based or instant markers was running on the same thread. + // All flows in our incomingContextFlows set have this flow in their + // outgoingContextFlows set. + incomingContextFlows: IndexIntoFlowTable[], + // Flows which have at least one stack-based or instant marker in their marker + // set which was running when one of the stack-based markers in our set was + // running higher up on the same thread's stack. + // All flows in our outgoingContextFlows set have this flow in their + // incomingContextFlows set. + outgoingContextFlows: IndexIntoFlowTable[], +}; + +type FlowIDAndTerminating = { + flowID: string, + isTerminating: boolean, +}; + +export type FlowMarker = { + markerIndex: number, + time: Milliseconds, + // The index of the closest stack-based interval flow marker that encompasses + // this marker. ("Closest" means "with the most recent start time".) + // If non-null, parentContextFlowMarker is lower the index of this flow marker, + // i.e. this can only point "backwards" within the thread's flow markers array. + parentContextFlowMarker: number | null, + // The indexes of flow markers which have this flow marker as their parentContextFlowMarker. + // All indexes in this array after the index of this flow marker. + childContextFlowMarkers: number[], + flowIDs: FlowIDAndTerminating[], +}; + +class MinHeap { + _keys: number[] = []; + _values: V[] = []; + + size(): number { + return this._values.length; + } + insert(key: number, value: V) { + this._keys.push(key); + this._values.push(value); + } + delete(handle: number) { + this._keys.splice(handle, 1); + this._values.splice(handle, 1); + } + first(): number | null { + if (this._values.length === 0) { + return null; + } + + let minI = 0; + let minKey = this._keys[0]; + for (let i = 1; i < this._keys.length; i++) { + const k = this._keys[i]; + if (k < minKey) { + minI = i; + minKey = k; + } + } + return minI; + } + get(handle: number): V { + return this._values[handle]; + } + reorder(handle: number, newKey: number) { + this._keys[handle] = newKey; + } +} + +export type FlowFieldDescriptor = { + key: string, + isTerminating: boolean, +}; + +export type FlowSchema = { + flowFields: FlowFieldDescriptor[], + isStackBased: boolean, +}; + +export type FlowSchemasByName = Map; + +export function computeFlowSchemasByName( + markerSchemas: MarkerSchema[] +): FlowSchemasByName { + const flowSchemasByName = new Map(); + for (const schema of markerSchemas) { + const flowFields = []; + for (const field of schema.fields) { + const key = field.key; + if (field.format === 'flow-id') { + flowFields.push({ key, isTerminating: false }); + } else if (field.format === 'terminating-flow-id') { + flowFields.push({ key, isTerminating: true }); + } + } + if (flowFields.length !== 0) { + flowSchemasByName.set(schema.name, { + flowFields, + isStackBased: schema.isStackBased === true, + }); + } + } + return flowSchemasByName; +} + +export function computeFlowMarkers( + fullMarkerList: Marker[], + stringArray: string[], + flowSchemasByName: FlowSchemasByName +): FlowMarker[] { + const flowMarkers: FlowMarker[] = []; + const currentContextFlowMarkers: number[] = []; + const currentContextEndTimes: Milliseconds[] = []; + for ( + let markerIndex = 0; + markerIndex < fullMarkerList.length; + markerIndex++ + ) { + const marker = fullMarkerList[markerIndex]; + const markerData = marker.data; + if (!markerData) { + continue; + } + const schemaName = markerData.type; + if (!schemaName) { + continue; + } + const flowSchema = flowSchemasByName.get(schemaName); + if (flowSchema === undefined) { + continue; + } + + const time = marker.start; + + while ( + currentContextEndTimes.length !== 0 && + currentContextEndTimes[currentContextEndTimes.length - 1] < time + ) { + currentContextEndTimes.pop(); + currentContextFlowMarkers.pop(); + } + + const flowIDs = []; + for (const { key, isTerminating } of flowSchema.flowFields) { + const flowIDStringIndex = (markerData as any)[key]; + if (flowIDStringIndex !== undefined && flowIDStringIndex !== null) { + const flowID = stringArray[flowIDStringIndex]; + flowIDs.push({ flowID, isTerminating }); + } + } + if (flowIDs.length === 0) { + continue; + } + + const thisFlowMarkerIndex = flowMarkers.length; + const parentContextFlowMarker = + currentContextFlowMarkers.length !== 0 + ? currentContextFlowMarkers[currentContextFlowMarkers.length - 1] + : null; + flowMarkers.push({ + parentContextFlowMarker, + childContextFlowMarkers: [], + flowIDs, + time, + markerIndex, + }); + if (flowSchema.isStackBased || marker.end === null) { + if (parentContextFlowMarker !== null) { + flowMarkers[parentContextFlowMarker].childContextFlowMarkers.push( + thisFlowMarkerIndex + ); + } + } + if (flowSchema.isStackBased && marker.end !== null) { + currentContextEndTimes.push(marker.end); + currentContextFlowMarkers.push(thisFlowMarkerIndex); + } + } + return flowMarkers; +} + +export type ProfileFlowInfo = { + flowTable: Flow[], + flowsByID: Map, + flowMarkersPerThread: FlowMarker[][], + flowMarkerFlowsPerThread: IndexIntoFlowTable[][][], + flowSchemasByName: FlowSchemasByName, +}; + +export function computeProfileFlowInfo( + fullMarkerListPerThread: Marker[][], + _threads: RawThread[], + markerSchemas: MarkerSchema[], + shared: RawProfileSharedData, +): ProfileFlowInfo { + const flowSchemasByName = computeFlowSchemasByName(markerSchemas); + const { stringArray } = shared; + + const flowMarkersPerThread: FlowMarker[][] = fullMarkerListPerThread.map( + (fullMarkerList) => { + return computeFlowMarkers(fullMarkerList, stringArray, flowSchemasByName); + } + ); + + const threadCount = flowMarkersPerThread.length; + const nextEntryHeap = new MinHeap<{threadIndex: number, nextIndex: number}>(); + for (let threadIndex = 0; threadIndex < threadCount; threadIndex++) { + const flowMarkers = flowMarkersPerThread[threadIndex]; + if (flowMarkers.length !== 0) { + nextEntryHeap.insert(flowMarkers[0].time, { + threadIndex, + nextIndex: 0, + }); + } + } + + const flowMarkerFlowsPerThread: IndexIntoFlowTable[][][] = flowMarkersPerThread.map(() => []); + + const flowTable = []; + const currentActiveFlows = new Map(); + const flowsByID = new Map(); + + while (true) { + const handle = nextEntryHeap.first(); + if (handle === null) { + break; + } + + const nextEntry = nextEntryHeap.get(handle); + const { threadIndex, nextIndex } = nextEntry; + const flowMarkerIndex = nextIndex; + const flowMarkers = flowMarkersPerThread[threadIndex]; + const flowReference = flowMarkers[nextIndex]; + + const { flowIDs, time } = flowReference; + const flowMarkerHandle = { threadIndex, flowMarkerIndex }; + const flowsForThisFlowMarker = []; + for (const { flowID, isTerminating } of flowIDs) { + let flowIndex = currentActiveFlows.get(flowID); + if (flowIndex === undefined) { + flowIndex = flowTable.length; + flowTable.push({ + id: flowID, + startTime: time, + endTime: time, + flowMarkers: [flowMarkerHandle], + }); + if (!isTerminating) { + currentActiveFlows.set(flowID, flowIndex); + } + const flowsByIDEntry = flowsByID.get(flowID); + if (flowsByIDEntry === undefined) { + flowsByID.set(flowID, [flowIndex]); + } else { + flowsByIDEntry.push(flowIndex); + } + } else { + const flow = flowTable[flowIndex]; + flow.flowMarkers.push(flowMarkerHandle); + flow.endTime = time; + if (isTerminating) { + currentActiveFlows.delete(flowID); + } + } + flowsForThisFlowMarker.push(flowIndex); + } + sortAndDedup(flowsForThisFlowMarker); + flowMarkerFlowsPerThread[threadIndex][flowMarkerIndex] = + flowsForThisFlowMarker; + + const newNextIndex = nextIndex + 1; + if (newNextIndex < flowMarkers.length) { + nextEntry.nextIndex = newNextIndex; + nextEntryHeap.reorder(handle, flowMarkers[newNextIndex].time); + } else { + nextEntryHeap.delete(handle); + } + } + + return { + flowTable, + flowsByID, + flowMarkersPerThread, + flowMarkerFlowsPerThread, + flowSchemasByName, + }; +} + +export function getConnectedFlowInfo( + flowIndex: IndexIntoFlowTable, + profileFlowInfo: ProfileFlowInfo +): ConnectedFlowInfo { + const { flowTable, flowMarkersPerThread, flowMarkerFlowsPerThread } = + profileFlowInfo; + const directlyConnectedFlows: IndexIntoFlowTable[] = []; + const incomingContextFlows: IndexIntoFlowTable[] = []; + const outgoingContextFlows: IndexIntoFlowTable[] = []; + + const flow = flowTable[flowIndex]; + for (const { threadIndex, flowMarkerIndex } of flow.flowMarkers) { + const thisMarkerFlows = + flowMarkerFlowsPerThread[threadIndex][flowMarkerIndex]; + for (const directlyConnectedFlowIndex of thisMarkerFlows) { + if (directlyConnectedFlowIndex !== flowIndex) { + directlyConnectedFlows.push(directlyConnectedFlowIndex); + } + } + + const flowMarker: FlowMarker = + flowMarkersPerThread[threadIndex][flowMarkerIndex]; + + const incomingFlowMarkerIndex = flowMarker.parentContextFlowMarker; + if (incomingFlowMarkerIndex !== null) { + const incomingMarkerFlows = + flowMarkerFlowsPerThread[threadIndex][incomingFlowMarkerIndex]; + for (const incomingContextFlowIndex of incomingMarkerFlows) { + if (incomingContextFlowIndex !== flowIndex) { + incomingContextFlows.push(incomingContextFlowIndex); + } + } + } + + for (const outgoingFlowMarkerIndex of flowMarker.childContextFlowMarkers) { + const outgoingMarkerFlows = + flowMarkerFlowsPerThread[threadIndex][outgoingFlowMarkerIndex]; + for (const outgoingContextFlowIndex of outgoingMarkerFlows) { + if (outgoingContextFlowIndex !== flowIndex) { + outgoingContextFlows.push(outgoingContextFlowIndex); + } + } + } + } + sortAndDedup(directlyConnectedFlows); + sortAndDedup(incomingContextFlows); + sortAndDedup(outgoingContextFlows); + return { + directlyConnectedFlows, + incomingContextFlows, + outgoingContextFlows, + }; +} + +export function lookupFlow( + flowID: string, + time: Milliseconds, + profileFlowInfo: ProfileFlowInfo +): IndexIntoFlowTable | null { + const { flowsByID, flowTable } = profileFlowInfo; + const candidateFlows = flowsByID.get(flowID); + if (candidateFlows === undefined) { + return null; + } + const index = + bisectionRightByKey( + candidateFlows, + time, + (flowIndex) => flowTable[flowIndex].startTime + ) - 1; + if (index === -1) { + return null; + } + return candidateFlows[index]; +} + +export function computeMarkerFlowsForConsole( + threadIndex: number, + markerIndex: MarkerIndex, + profileFlowInfo: ProfileFlowInfo, + _threads: RawThread[], + fullMarkerListPerThread: Marker[][], + stringTable: StringTable +): IndexIntoFlowTable[] | null { + const marker = fullMarkerListPerThread[threadIndex][markerIndex]; + const markerData = marker.data; + if (!markerData) { + return null; + } + const markerType = markerData.type; + if (!markerType) { + return null; + } + const flowSchema = profileFlowInfo.flowSchemasByName.get(markerType); + if (flowSchema === undefined) { + return null; + } + + const flowIndexes = []; + for (const { key } of flowSchema.flowFields) { + const fieldValue = (markerData as any)[key]; + if (fieldValue === undefined || fieldValue === null) { + continue; + } + const flowID = stringTable.getString(fieldValue); + const flowIndex = lookupFlow(flowID, marker.start, profileFlowInfo); + if (flowIndex === null) { + console.error( + `Could not find flow for ID ${flowID} at time ${marker.start}!` + ); + continue; + } + flowIndexes.push(flowIndex); + } + dedupConsecutive(flowIndexes); + return flowIndexes.length !== 0 ? flowIndexes : null; +} + +function sortAndDedup(array: number[]) { + array.sort((a, b) => a - b); + dedupConsecutive(array); +} + +function dedupConsecutive(array: T[]) { + if (array.length === 0) { + return; + } + + let prev = array[0]; + for (let i = 1; i < array.length; i++) { + const curr = array[i]; + if (prev === curr) { + array.splice(i, 1); + i--; + } else { + prev = curr; + } + } +} + +export function printMarkerFlows( + markerThreadIndex: number, + markerIndex: MarkerIndex, + profileFlowInfo: ProfileFlowInfo, + threads: RawThread[], + fullMarkerListPerThread: Marker[][], + stringTable: StringTable +) { + const markerFlows = computeMarkerFlowsForConsole( + markerThreadIndex, + markerIndex, + profileFlowInfo, + threads, + fullMarkerListPerThread, + stringTable + ); + if (markerFlows === null) { + console.log('This marker is not part of any flows.'); + return; + } + + const { flowTable } = profileFlowInfo; + const flowCount = markerFlows.length; + if (flowCount === 1) { + const flowIndex = markerFlows[0]; + console.log( + `This marker is part of one flow: ${flowTable[flowIndex].id} (index ${flowIndex})` + ); + } else { + console.log( + `This marker is part of ${flowCount} flows:`, + markerFlows.map((flowIndex) => flowTable[flowIndex].id) + ); + } + + for (const flowIndex of markerFlows) { + printFlow(flowIndex, profileFlowInfo, threads, fullMarkerListPerThread); + } +} + +export function printFlow( + flowIndex: IndexIntoFlowTable, + profileFlowInfo: ProfileFlowInfo, + threads: RawThread[], + fullMarkerListPerThread: Marker[][] +) { + const { flowTable, flowMarkersPerThread, flowMarkerFlowsPerThread } = + profileFlowInfo; + + const flow = flowTable[flowIndex]; + console.log(`Flow ${flow.id} (index ${flowIndex}):`, flow); + console.log(`This flow contains ${flow.flowMarkers.length} markers:`); + for (const { threadIndex, flowMarkerIndex } of flow.flowMarkers) { + const flowMarker = flowMarkersPerThread[threadIndex][flowMarkerIndex]; + const otherMarkerIndex = flowMarker.markerIndex; + const thread = threads[threadIndex]; + const marker = fullMarkerListPerThread[threadIndex][otherMarkerIndex]; + console.log( + ` - marker ${otherMarkerIndex} (thread index: ${threadIndex}) at time ${flowMarker.time} on thread ${thread.name} (pid: ${thread.pid}, tid: ${thread.tid}):`, + marker + ); + const directlyConnectedFlows = flowMarkerFlowsPerThread[threadIndex][ + flowMarkerIndex + ].filter((otherFlowIndex) => otherFlowIndex !== flowIndex); + const incomingContextFlows = + flowMarker.parentContextFlowMarker !== null + ? flowMarkerFlowsPerThread[threadIndex][ + flowMarker.parentContextFlowMarker + ].filter((otherFlowIndex) => otherFlowIndex !== flowIndex) + : []; + const outgoingContextFlows = []; + for (const childFlowMarkerIndex of flowMarker.childContextFlowMarkers) { + for (const outgoingFlow of flowMarkerFlowsPerThread[threadIndex][ + childFlowMarkerIndex + ]) { + if (outgoingFlow !== flowIndex) { + outgoingContextFlows.push(outgoingFlow); + } + } + } + sortAndDedup(outgoingContextFlows); + if (directlyConnectedFlows.length !== 0) { + console.log( + `Directly connected flows on this marker: ${directlyConnectedFlows.join(', ')}` + ); + } + if (incomingContextFlows.length !== 0) { + console.log( + `Incoming context flows on this marker: ${incomingContextFlows.join(', ')}` + ); + } + if (outgoingContextFlows.length !== 0) { + console.log( + `Outgoing context flows on this marker: ${outgoingContextFlows.join(', ')}` + ); + } + } + + // const connections = getConnectedFlowInfo(flowIndex, profileFlowInfo); + // if (connections.directlyConnectedFlows.length !== 0) { + // console.log( + // `Directly connected flows: ${connections.directlyConnectedFlows.join(', ')}` + // ); + // } + // if (connections.incomingContextFlows.length !== 0) { + // console.log( + // `Incoming context flows: ${connections.incomingContextFlows.join(', ')}` + // ); + // } + // if (connections.outgoingContextFlows.length !== 0) { + // console.log( + // `Outgoing context flows: ${connections.outgoingContextFlows.join(', ')}` + // ); + // } +} diff --git a/src/selectors/flow.ts b/src/selectors/flow.ts new file mode 100644 index 0000000000..ebdebd1bc9 --- /dev/null +++ b/src/selectors/flow.ts @@ -0,0 +1,49 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// @flow +import { createSelector } from 'reselect'; + +import { computeProfileFlowInfo } from '../profile-logic/marker-data'; +import type { ProfileFlowInfo } from '../profile-logic/marker-data'; +import { getThreadSelectors } from './per-thread'; +import type { ThreadSelectors } from './per-thread'; +import { getThreads, getMarkerSchema, getRawProfileSharedData } from './profile'; + +import type { Selector, State, Marker } from 'firefox-profiler/types'; + +function _arraysShallowEqual(arr1: any[], arr2: any[]): boolean { + return arr1.length === arr2.length && arr1.every((val, i) => val === arr2[i]); +} + +function _createSelectorForAllThreads( + f: (selectors: ThreadSelectors, state: State) => T +): Selector { + let previousOutputPerThread: T[] = []; + return function recomputeSelectorForAllThreads(state: State): T[] { + const threads = getThreads(state); + let outputPerThread = threads.map((_thread, i) => { + const threadSelectors = getThreadSelectors(i); + return f(threadSelectors, state); + }); + if (_arraysShallowEqual(outputPerThread, previousOutputPerThread)) { + outputPerThread = previousOutputPerThread; + } + previousOutputPerThread = outputPerThread; + return outputPerThread; + }; +} + +export const getFullMarkerListPerThread: Selector = + _createSelectorForAllThreads(({ getFullMarkerList }, state) => + getFullMarkerList(state) + ); + +export const getProfileFlowInfo: Selector = createSelector( + getFullMarkerListPerThread, + getThreads, + getMarkerSchema, + getRawProfileSharedData, + computeProfileFlowInfo +); diff --git a/src/selectors/index.ts b/src/selectors/index.ts index 69b552d23e..bf8ef154b6 100644 --- a/src/selectors/index.ts +++ b/src/selectors/index.ts @@ -10,6 +10,7 @@ export * from './publish'; export * from './zipped-profiles'; export * from './cpu'; export * from './code'; +export * from './flow'; import * as app from './app'; import { @@ -23,6 +24,7 @@ import * as publish from './publish'; import * as zippedProfiles from './zipped-profiles'; import * as cpu from './cpu'; import * as code from './code'; +import * as flow from './flow'; const _selectorsForConsole = { app, @@ -35,6 +37,7 @@ const _selectorsForConsole = { selectedNode, cpu, code, + flow, }; // Exports require explicit typing. Deduce the type with typeof. diff --git a/src/utils/window-console.ts b/src/utils/window-console.ts index ac749ab519..675b91671a 100644 --- a/src/utils/window-console.ts +++ b/src/utils/window-console.ts @@ -27,6 +27,8 @@ import type { CallTree } from 'firefox-profiler/profile-logic/call-tree'; import { formatLogTimestamp, formatLogStatement, + printMarkerFlows, + printFlow, } from 'firefox-profiler/profile-logic/marker-data'; import { computeStringIndexMarkerFieldsByDataType } from 'firefox-profiler/profile-logic/marker-schema'; @@ -42,6 +44,9 @@ export type ExtraPropertiesOnWindowForConsole = { callTree: CallTree; filteredMarkers: Marker[]; selectedMarker: Marker | null; + printFlows: () => void; + selectMarkerOnThread: (markerIndex: number, threadIndex: number) => void; + printFlow: (flowIndex: number) => void; experimental: { enableEventDelayTracks(): void; enableCPUGraphs(): void; @@ -119,6 +124,44 @@ export function addDataToWindowObject( }, }); + target.printFlows = function () { + const threadIndex = + selectorsForConsole.urlState.getFirstSelectedThreadIndex(getState()); + const markerIndex = + selectorsForConsole.selectedThread.getSelectedMarkerIndex(getState()); + const profileFlowInfo = + selectorsForConsole.flow.getProfileFlowInfo(getState()); + const threads = selectorsForConsole.profile.getThreads(getState()); + const fullMarkerListPerThread = + selectorsForConsole.flow.getFullMarkerListPerThread(getState()); + const stringTable = selectorsForConsole.profile.getStringTable(getState()); + if (markerIndex === null) { + console.log('No marker is selected.'); + } else { + printMarkerFlows( + threadIndex, + markerIndex, + profileFlowInfo, + threads, + fullMarkerListPerThread, + stringTable + ); + } + }; + target.selectMarkerOnThread = function (markerIndex, threadIndex) { + dispatch(actions.showProvidedThreads(new Set([threadIndex]))); + dispatch(actions.changeSelectedThreads(new Set([threadIndex]))); + dispatch(actions.changeSelectedMarker(threadIndex, markerIndex)); + }; + target.printFlow = function (flowIndex) { + const profileFlowInfo = + selectorsForConsole.flow.getProfileFlowInfo(getState()); + const threads = selectorsForConsole.profile.getThreads(getState()); + const fullMarkerListPerThread = + selectorsForConsole.flow.getFullMarkerListPerThread(getState()); + printFlow(flowIndex, profileFlowInfo, threads, fullMarkerListPerThread); + }; + target.experimental = { enableEventDelayTracks() { const areEventDelayTracksEnabled = dispatch( From 028085df3088b8ac203a6326e0bb255049fb6d98 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sat, 9 Nov 2024 16:49:48 +0100 Subject: [PATCH 3/5] Group stack-based and instant markers at the top of the marker chart. --- src/profile-logic/marker-timing.ts | 109 ++++++++++++++++++---------- src/selectors/per-thread/markers.ts | 3 +- 2 files changed, 73 insertions(+), 39 deletions(-) diff --git a/src/profile-logic/marker-timing.ts b/src/profile-logic/marker-timing.ts index b8ee8dd440..49d8b983cf 100644 --- a/src/profile-logic/marker-timing.ts +++ b/src/profile-logic/marker-timing.ts @@ -8,12 +8,31 @@ import type { MarkerIndex, MarkerTiming, MarkerTimingAndBuckets, + MarkerSchemaByName, } from 'firefox-profiler/types'; +import { getSchemaFromMarker } from './marker-schema'; + // Arbitrarily set an upper limit for adding marker depths, avoiding very long // overlapping marker timings. const MAX_STACKING_DEPTH = 300; +function _createEmptyTiming( + name: string, + bucket: string, + instantOnly: boolean +): MarkerTiming { + return { + start: [], + end: [], + index: [], + name, + bucket, + instantOnly, + length: 0, + }; +} + /** * This function computes the timing information for laying out the markers in the * MarkerChart component. Each marker is put into a single row based on its name. In @@ -86,13 +105,15 @@ export function getMarkerTiming( markerIndexes: MarkerIndex[], // Categories can be null for things like Network Markers, where we don't care to // break things up by category. - categories: CategoryList | null + categories?: CategoryList, + markerSchemaByName?: MarkerSchemaByName ): MarkerTiming[] { - // Each marker type will have it's own timing information, later collapse these into + // Each marker type will have its own timing information, later collapse these into // a single array. const intervalMarkerTimingsMap: Map = new Map(); // Instant markers are on separate lines. const instantMarkerTimingsMap: Map = new Map(); + const stackBasedMarkerTimings: MarkerTiming[] = []; // Go through all of the markers. for (const markerIndex of markerIndexes) { @@ -109,35 +130,41 @@ export function getMarkerTiming( markerTiming.length++; }; - const bucketName = categories ? categories[marker.category].name : 'None'; + const schema = markerSchemaByName + ? getSchemaFromMarker(markerSchemaByName, marker.data) + : null; + let isStackBased = schema !== null && schema.isStackBased === true; + if (marker.end === null) { + // XXXmstange let's see how we like this + isStackBased = true; + } + + // eslint-disable-next-line no-nested-ternary + const bucketName = isStackBased + ? 'Stack' + : categories + ? categories[marker.category].name + : 'None'; // We want to group all network requests in the same line. Indeed they all // have different names and they'd end up with one single request in each // line without this special handling. - const markerLineName = - marker.data && marker.data.type === 'Network' + // eslint-disable-next-line no-nested-ternary + const markerLineName = isStackBased + ? 'Stack' + : marker.data && marker.data.type === 'Network' ? 'Network Requests' : marker.name; - const emptyTiming = ({ - instantOnly, - }: { - instantOnly: boolean; - }): MarkerTiming => ({ - start: [], - end: [], - index: [], - name: markerLineName, - bucket: bucketName, - instantOnly, - length: 0, - }); - - if (marker.end === null) { + if (marker.end === null && !isStackBased) { // This is an instant marker. let instantMarkerTiming = instantMarkerTimingsMap.get(markerLineName); if (!instantMarkerTiming) { - instantMarkerTiming = emptyTiming({ instantOnly: true }); + instantMarkerTiming = _createEmptyTiming( + markerLineName, + bucketName, + true + ); instantMarkerTimingsMap.set(markerLineName, instantMarkerTiming); } addCurrentMarkerToMarkerTiming(instantMarkerTiming); @@ -145,16 +172,18 @@ export function getMarkerTiming( } // This is an interval marker. - let markerTimingsForName = intervalMarkerTimingsMap.get(markerLineName); - if (markerTimingsForName === undefined) { - markerTimingsForName = []; - intervalMarkerTimingsMap.set(markerLineName, markerTimingsForName); + let timingRows = isStackBased + ? stackBasedMarkerTimings + : intervalMarkerTimingsMap.get(markerLineName); + if (timingRows === undefined) { + timingRows = []; + intervalMarkerTimingsMap.set(markerLineName, timingRows); } // Find the first row where the new marker fits. // Since the markers are sorted, look at the last added marker in this row. If // the new marker fits, go ahead and insert it. - const foundMarkerTiming = markerTimingsForName.find( + const foundMarkerTiming = timingRows.find( (markerTiming) => markerTiming.end[markerTiming.length - 1] <= marker.start ); @@ -163,30 +192,34 @@ export function getMarkerTiming( addCurrentMarkerToMarkerTiming(foundMarkerTiming); continue; } - - if (markerTimingsForName.length >= MAX_STACKING_DEPTH) { + if (timingRows.length >= MAX_STACKING_DEPTH) { // There are too many markers stacked around the same time already, let's // ignore this marker. continue; } // Otherwise, let's add a new row! - const newTiming = emptyTiming({ instantOnly: false }); + const newTiming = _createEmptyTiming(markerLineName, bucketName, false); addCurrentMarkerToMarkerTiming(newTiming); - markerTimingsForName.push(newTiming); + timingRows.push(newTiming); continue; } - // Flatten out the maps into a single array. // One item in this array is one line in the drawn canvas. - const allMarkerTimings = [...instantMarkerTimingsMap.values()].concat( - ...intervalMarkerTimingsMap.values() - ); + const allMarkerTimings = [...instantMarkerTimingsMap.values()] + .concat(...intervalMarkerTimingsMap.values()) + .concat(...stackBasedMarkerTimings); // Sort all the marker timings in place, first by the bucket, then by their names. allMarkerTimings.sort((a, b) => { if (a.bucket !== b.bucket) { // Sort by buckets first. + if (a.bucket === 'Stack') { + return -1; + } + if (b.bucket === 'Stack') { + return 1; + } // Show the 'Test' category first. Test markers are almost guaranteed to // be the most relevant when they exist. if (a.bucket === 'Test') { @@ -286,16 +319,18 @@ export function getMarkerTiming( * ] */ export function getMarkerTimingAndBuckets( - getMarker: (param: MarkerIndex) => Marker, + getMarker: (marker: MarkerIndex) => Marker, markerIndexes: MarkerIndex[], // Categories can be null for things like Network Markers, where we don't care to // break things up by category. - categories: CategoryList | null + categories?: CategoryList, + markerSchemaByName?: MarkerSchemaByName ): MarkerTimingAndBuckets { const allMarkerTimings = getMarkerTiming( getMarker, markerIndexes, - categories + categories, + markerSchemaByName ); // Interleave the bucket names in between the marker timings. diff --git a/src/selectors/per-thread/markers.ts b/src/selectors/per-thread/markers.ts index d80a0d3ec0..313fa9deaa 100644 --- a/src/selectors/per-thread/markers.ts +++ b/src/selectors/per-thread/markers.ts @@ -472,6 +472,7 @@ export function getMarkerSelectorsPerThread( getMarkerGetter, getMarkerChartMarkerIndexes, ProfileSelectors.getCategories, + ProfileSelectors.getMarkerSchemaByName, MarkerTimingLogic.getMarkerTimingAndBuckets ); @@ -535,7 +536,6 @@ export function getMarkerSelectorsPerThread( const getNetworkTrackTiming: Selector = createSelector( getMarkerGetter, getNetworkMarkerIndexes, - () => null, MarkerTimingLogic.getMarkerTiming ); @@ -546,7 +546,6 @@ export function getMarkerSelectorsPerThread( const getUserTimingMarkerTiming: Selector = createSelector( getMarkerGetter, getUserTimingMarkerIndexes, - () => null, MarkerTimingLogic.getMarkerTiming ); From 265f133eec8b9e04e066b56ecff021b743ba1735 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Thu, 14 Nov 2024 01:25:56 +0100 Subject: [PATCH 4/5] Add a flow panel. --- src/actions/profile-view.ts | 38 + src/app-logic/url-handling.ts | 7 + src/components/app/Details.tsx | 4 +- src/components/app/DetailsContainer.css | 1 - src/components/flow-panel/Canvas.tsx | 1101 +++++++++++++++++++++ src/components/flow-panel/index.css | 13 + src/components/flow-panel/index.tsx | 179 ++++ src/components/marker-chart-tab/index.css | 7 + src/components/marker-chart-tab/index.tsx | 30 + src/components/marker-chart/Canvas.tsx | 45 +- src/components/marker-chart/index.tsx | 12 +- src/components/tooltip/FlowGap.tsx | 110 ++ src/profile-logic/marker-data.ts | 363 +++++-- src/reducers/url-state.ts | 13 + src/selectors/flow.ts | 34 +- src/selectors/url-state.ts | 3 + src/types/actions.ts | 5 + src/types/profile-derived.ts | 114 +++ src/types/state.ts | 2 + 19 files changed, 1946 insertions(+), 135 deletions(-) create mode 100644 src/components/flow-panel/Canvas.tsx create mode 100644 src/components/flow-panel/index.css create mode 100644 src/components/flow-panel/index.tsx create mode 100644 src/components/marker-chart-tab/index.css create mode 100644 src/components/marker-chart-tab/index.tsx create mode 100644 src/components/tooltip/FlowGap.tsx diff --git a/src/actions/profile-view.ts b/src/actions/profile-view.ts index 3c94e1ed3f..4044514da3 100644 --- a/src/actions/profile-view.ts +++ b/src/actions/profile-view.ts @@ -14,6 +14,7 @@ import { getPreviewSelection, getLocalTracksByPid, getThreads, + getStringTable, getLastNonShiftClick, getReservedFunctionsForResources, } from 'firefox-profiler/selectors/profile'; @@ -22,6 +23,10 @@ import { getThreadSelectorsFromThreadsKey, selectedThreadSelectors, } from 'firefox-profiler/selectors/per-thread'; +import { + getProfileFlowInfo, + getFullMarkerListPerThread, +} from 'firefox-profiler/selectors/flow'; import { getAllCommittedRanges, getImplementationFilter, @@ -75,11 +80,13 @@ import type { TableViewOptions, SelectionContext, BottomBoxInfo, + IndexIntoFlowTable, } from 'firefox-profiler/types'; import { funcHasDirectRecursiveCall, funcHasRecursiveCall, } from '../profile-logic/transforms'; +import { computeMarkerFlows } from '../profile-logic/marker-data'; import { changeStoredProfileNameInDb } from 'firefox-profiler/app-logic/uploaded-profiles-db'; import type { TabSlug } from '../app-logic/tabs-handling'; import type { CallNodeInfo } from '../profile-logic/call-node-info'; @@ -1640,6 +1647,37 @@ export function changeHoveredMarker( }; } +export function changeActiveFlows(activeFlows: IndexIntoFlowTable[]): Action { + return { + type: 'CHANGE_ACTIVE_FLOWS', + activeFlows, + }; +} + +export function activateFlowsForMarker( + threadIndex: ThreadIndex, + markerIndex: MarkerIndex +): ThunkAction { + console.log('yo'); + return (dispatch, getState) => { + console.log('aha'); + const profileFlowInfo = getProfileFlowInfo(getState()); + const fullMarkerListPerThread = getFullMarkerListPerThread(getState()); + const stringTable = getStringTable(getState()); + console.log('aha2'); + const flows = + computeMarkerFlows( + threadIndex, + markerIndex, + profileFlowInfo, + fullMarkerListPerThread, + stringTable + ) ?? []; + console.log({ flows }); + dispatch(changeActiveFlows(flows)); + }; +} + /** * This action is used when the user right clicks a marker, and is especially * used to display its context menu. diff --git a/src/app-logic/url-handling.ts b/src/app-logic/url-handling.ts index 60c6b32a4c..c096ccc842 100644 --- a/src/app-logic/url-handling.ts +++ b/src/app-logic/url-handling.ts @@ -180,6 +180,7 @@ type BaseQuery = { timelineType: string; sourceViewIndex: number; assemblyView: string; + activeFlows: string; }; type CallTreeQuery = BaseQuery & { @@ -409,6 +410,9 @@ export function getQueryStringFromUrlState(urlState: UrlState): string { urlState.profileSpecific.markerTableSort ); } + query.activeFlows = + encodeUintArrayForUrlComponent(urlState.profileSpecific.activeFlows) || + undefined; break; case 'network-chart': query = baseQuery as NetworkQueryShape; @@ -538,6 +542,8 @@ export function stateFromLocation( implementation = query.implementation; } + const activeFlows = decodeUintArrayFromUrlComponent(query.activeFlows ?? ''); + const transforms: { [key: string]: Transform[] } = {}; if (selectedThreadsKey !== null) { transforms[selectedThreadsKey] = parseTransforms(query.transforms); @@ -624,6 +630,7 @@ export function stateFromLocation( transforms, sourceView, assemblyView, + activeFlows, isBottomBoxOpenPerPanel, isBottomBoxFullscreen: query.bottomFullscreen || false, timelineType: validateTimelineType(query.timelineType), diff --git a/src/components/app/Details.tsx b/src/components/app/Details.tsx index 643e6c5929..10ebb23edb 100644 --- a/src/components/app/Details.tsx +++ b/src/components/app/Details.tsx @@ -12,7 +12,7 @@ import { LocalizedErrorBoundary } from './ErrorBoundary'; import { ProfileCallTreeView } from 'firefox-profiler/components/calltree/ProfileCallTreeView'; import { MarkerTable } from 'firefox-profiler/components/marker-table'; import { StackChart } from 'firefox-profiler/components/stack-chart/'; -import { MarkerChart } from 'firefox-profiler/components/marker-chart/'; +import { MarkerChartTab } from 'firefox-profiler/components/marker-chart-tab/'; import { NetworkChart } from 'firefox-profiler/components/network-chart/'; import { FlameGraph } from 'firefox-profiler/components/flame-graph/'; import { JsTracer } from 'firefox-profiler/components/js-tracer/'; @@ -124,7 +124,7 @@ class ProfileViewerImpl extends PureComponent { calltree: , 'flame-graph': , 'stack-chart': , - 'marker-chart': , + 'marker-chart': , 'marker-table': , 'network-chart': , 'js-tracer': , diff --git a/src/components/app/DetailsContainer.css b/src/components/app/DetailsContainer.css index 083505c802..6a26dddbea 100644 --- a/src/components/app/DetailsContainer.css +++ b/src/components/app/DetailsContainer.css @@ -1,6 +1,5 @@ .DetailsContainer { display: flex; - box-sizing: border-box; flex: 1; flex-flow: row nowrap; contain: size; diff --git a/src/components/flow-panel/Canvas.tsx b/src/components/flow-panel/Canvas.tsx new file mode 100644 index 0000000000..cf5b3878b8 --- /dev/null +++ b/src/components/flow-panel/Canvas.tsx @@ -0,0 +1,1101 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// @flow +import { GREY_20, BLUE_60, BLUE_80 } from 'photon-colors'; +import * as React from 'react'; +import memoize from 'memoize-immutable'; +import { + withChartViewport, + type Viewport, +} from 'firefox-profiler/components/shared/chart/Viewport'; +import { ChartCanvas } from 'firefox-profiler/components/shared/chart/Canvas'; +import { TooltipMarker } from 'firefox-profiler/components/tooltip/Marker'; +import { FlowGapTooltip } from 'firefox-profiler/components/tooltip/FlowGap'; +import TextMeasurement from 'firefox-profiler/utils/text-measurement'; +import { bisectionRight } from 'firefox-profiler/utils/bisect'; +import type { + updatePreviewSelection, + changeMouseTimePosition, + changeActiveFlows, +} from 'firefox-profiler/actions/profile-view'; +import { TIMELINE_MARGIN_LEFT } from 'firefox-profiler/app-logic/constants'; +import type { + Milliseconds, + CssPixels, + UnitIntervalOfProfileRange, + Marker, + MarkerIndex, + FlowTiming, + ThreadIndex, + IndexIntoFlowTable, + FlowTimingRow, + FlowTimingArrow, +} from 'firefox-profiler/types'; +import { + ensureExists, + assertExhaustiveCheck, +} from 'firefox-profiler/utils/types'; +import { computeArrowsRelatedToMarker } from 'firefox-profiler/profile-logic/marker-data'; + +import type { + ChartCanvasScale, + ChartCanvasHoverInfo, +} from '../shared/chart/Canvas'; + +import type { WrapFunctionInDispatch } from 'firefox-profiler/utils/connect'; + +type FlowPanelHoverInfo = { + rowIndex: number | null; + flowIndex: IndexIntoFlowTable | null; + hoveredItem: HoveredFlowPanelItem | null; +}; + +type HoveredFlowPanelItem = + | { + type: 'SINGLE_MARKER'; + hoveredMarker: HoveredFlowMarker; + } + | { + type: 'BETWEEN_MARKERS'; + markerBeforeHoveredGapAsIndexIntoFlowTimingRowMarkerTable: number; + markerBeforeHoveredGap: HoveredFlowMarker; + markerAfterHoveredGap: HoveredFlowMarker; + }; + +type HoveredFlowMarker = { + indexInFlowMarkers: number; // index into flows[flowIndex].flowMarkers + threadIndex: ThreadIndex; + markerIndex: MarkerIndex; + flowMarkerIndex: number; +}; + +type OwnProps = { + rangeStart: Milliseconds; + rangeEnd: Milliseconds; + flowTiming: FlowTiming; + rowHeight: CssPixels; + fullMarkerListPerThread: Marker[][]; + markerLabelGetterPerThread: Array<(marker: MarkerIndex) => string>; + updatePreviewSelection: WrapFunctionInDispatch; + changeMouseTimePosition: WrapFunctionInDispatch< + typeof changeMouseTimePosition + >; + changeActiveFlows: WrapFunctionInDispatch; + marginLeft: CssPixels; + marginRight: CssPixels; + shouldDisplayTooltips: () => boolean; +}; + +type Props = OwnProps & { + // Bring in the viewport props from the higher order Viewport component. + readonly viewport: Viewport; +}; + +const TEXT_OFFSET_TOP = 11; +const TEXT_OFFSET_START = 3; +const MARKER_DOT_RADIUS = 0.25; +const LABEL_PADDING = 5; +const MARKER_BORDER_COLOR = '#2c77d1'; + +class FlowPanelCanvasImpl extends React.PureComponent { + _textMeasurement: null | TextMeasurement = null; + + _memoizedGetArrows = memoize((threadIndex, flowMarkerIndex, flowTiming) => + computeArrowsRelatedToMarker(threadIndex, flowMarkerIndex, flowTiming) + ); + + drawCanvas = ( + ctx: CanvasRenderingContext2D, + scale: ChartCanvasScale, + hoverInfo: ChartCanvasHoverInfo + ) => { + const { + rowHeight, + flowTiming, + viewport: { + viewportTop, + viewportBottom, + containerWidth, + containerHeight, + }, + } = this.props; + const { cssToUserScale } = scale; + if (cssToUserScale !== 1) { + throw new Error( + 'StackChartCanvasImpl sets scaleCtxToCssPixels={true}, so canvas user space units should be equal to CSS pixels.' + ); + } + + const { hoveredItem } = hoverInfo; + + // Convert CssPixels to Stack Depth + const rowCount = flowTiming.rows.length; + const startRow = Math.floor(viewportTop / rowHeight); + const endRow = Math.min(Math.ceil(viewportBottom / rowHeight), rowCount); + + // Common properties that won't be changed later. + ctx.lineWidth = 1; + + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, containerWidth, containerHeight); + this.drawRowHighlights(ctx, startRow, endRow); + this.drawRowContents(ctx, null, startRow, endRow, hoveredItem); + this.drawSeparatorsAndLabels(ctx, startRow, endRow); + + const hoveredMarker = + hoveredItem !== null && + hoveredItem.hoveredItem && + hoveredItem.hoveredItem.type === 'SINGLE_MARKER' + ? hoveredItem.hoveredItem.hoveredMarker + : null; + + if (hoveredMarker !== null) { + const { threadIndex, flowMarkerIndex } = hoveredMarker; + if (threadIndex !== null && flowMarkerIndex !== null) { + const arrows = this._memoizedGetArrows( + threadIndex, + flowMarkerIndex, + flowTiming + ); + this.drawArrows(ctx, arrows, startRow, endRow); + } + } + }; + + drawRowHighlights( + ctx: CanvasRenderingContext2D, + startRow: number, + endRow: number + ) { + const { flowTiming } = this.props; + const { rows } = flowTiming; + for (let rowIndex = startRow; rowIndex < endRow; rowIndex++) { + const rowType = rows[rowIndex].rowType; + if (rowType === 'ACTIVE') { + this.drawRowHighlight(ctx, rowIndex); + } + } + } + + drawRowHighlight(ctx: CanvasRenderingContext2D, rowIndex: number) { + const { + rowHeight, + viewport: { viewportTop, containerWidth }, + } = this.props; + + ctx.fillStyle = 'rgba(40, 122, 169, 0.2)'; + ctx.fillRect( + 0, // To include the labels also + rowIndex * rowHeight - viewportTop, + containerWidth, + rowHeight - 1 // Subtract 1 for borders. + ); + } + + drawFlowRectangle( + ctx: CanvasRenderingContext2D, + rowIndex: number, + timeAtViewportLeft: number, + timeAtViewportRightPlusMargin: number, + rangeStart: number, + rangeLength: number, + viewportLeft: number, + markerContainerWidth: number, + viewportLength: number, + marginLeft: number + ) { + const { + rowHeight, + flowTiming, + viewport: { viewportTop }, + } = this.props; + const { rows } = flowTiming; + const { devicePixelRatio } = window; + + const row = rows[rowIndex]; + const startTimestamp = row.flowStart; + const endTimestamp = row.flowEnd; + + const y: CssPixels = rowIndex * rowHeight - viewportTop; + const h: CssPixels = rowHeight - 1; + + // Only draw samples that are in bounds. + if ( + !( + endTimestamp >= timeAtViewportLeft && + startTimestamp < timeAtViewportRightPlusMargin + ) + ) { + return; + } + const startTime: UnitIntervalOfProfileRange = + (startTimestamp - rangeStart) / rangeLength; + const endTime: UnitIntervalOfProfileRange = + (endTimestamp - rangeStart) / rangeLength; + + let x: CssPixels = + ((startTime - viewportLeft) * markerContainerWidth) / viewportLength + + marginLeft; + let w: CssPixels = + ((endTime - startTime) * markerContainerWidth) / viewportLength; + + x = Math.round(x * devicePixelRatio) / devicePixelRatio; + w = Math.round(w * devicePixelRatio) / devicePixelRatio; + + ctx.strokeStyle = 'rgba(0, 0, 0, 0.7)'; + ctx.beginPath(); + + // We want the rectangle to have a clear margin, that's why we increment y + // and decrement h (twice, for both margins). + // We also add "0.5" more so that the stroke is properly on a pixel. + // Indeed strokes are drawn on both sides equally, so half a pixel on each + // side in this case. + ctx.rect( + x + 0.5, // + 0.5 for the stroke + y + 1 + 0.5, // + 1 for the top margin, + 0.5 for the stroke + w - 1, // - 1 to account for left and right strokes. + h - 2 - 1 // + 2 accounts for top and bottom margins, + 1 accounts for top and bottom strokes + ); + ctx.stroke(); + } + + // Note: we used a long argument list instead of an object parameter on + // purpose, to reduce GC pressure while drawing. + drawOneMarker( + ctx: CanvasRenderingContext2D, + x: CssPixels, + y: CssPixels, + w: CssPixels, + h: CssPixels, + isInstantMarker: boolean, + markerIndex: MarkerIndex, + threadIndex: number, + isHighlighted: boolean + ) { + if (isInstantMarker) { + w = 1; + } + this.drawOneIntervalMarker( + ctx, + x, + y, + w, + h, + markerIndex, + threadIndex, + isHighlighted + ); + } + + drawOneIntervalMarker( + ctx: CanvasRenderingContext2D, + x: CssPixels, + y: CssPixels, + w: CssPixels, + h: CssPixels, + markerIndex: MarkerIndex, + threadIndex: number, + isHighlighted: boolean + ) { + const { marginLeft, markerLabelGetterPerThread } = this.props; + + if (w <= 2) { + // This is an interval marker small enough that if we drew it as a + // rectangle, we wouldn't see any inside part. With a width of 2 pixels, + // the rectangle-with-borders would only be borders. With less than 2 + // pixels, the borders would collapse. + // So let's draw it directly as a rect. + ctx.fillStyle = isHighlighted ? BLUE_80 : MARKER_BORDER_COLOR; + + // w is rounded in the caller, but let's make sure it's at least 1. + w = Math.max(w, 1); + ctx.fillRect(x, y + 1, w, h - 2); + } else { + // This is a bigger interval marker. + const textMeasurement = this._getTextMeasurement(ctx); + + ctx.fillStyle = isHighlighted ? BLUE_60 : '#8ac4ff'; + ctx.strokeStyle = isHighlighted ? BLUE_80 : MARKER_BORDER_COLOR; + + ctx.beginPath(); + + // We want the rectangle to have a clear margin, that's why we increment y + // and decrement h (twice, for both margins). + // We also add "0.5" more so that the stroke is properly on a pixel. + // Indeed strokes are drawn on both sides equally, so half a pixel on each + // side in this case. + ctx.rect( + x + 0.5, // + 0.5 for the stroke + y + 1 + 0.5, // + 1 for the top margin, + 0.5 for the stroke + w - 1, // - 1 to account for left and right strokes. + h - 2 - 1 // + 2 accounts for top and bottom margins, + 1 accounts for top and bottom strokes + ); + ctx.fill(); + ctx.stroke(); + + // Draw the text label + // TODO - L10N RTL. + // Constrain the x coordinate to the leftmost area. + const x2: CssPixels = + x < marginLeft ? marginLeft + TEXT_OFFSET_START : x + TEXT_OFFSET_START; + const visibleWidth = x < marginLeft ? w - marginLeft + x : w; + const w2: CssPixels = visibleWidth - 2 * TEXT_OFFSET_START; + + if (w2 > textMeasurement.minWidth) { + const fittedText = textMeasurement.getFittedText( + markerLabelGetterPerThread[threadIndex](markerIndex), + w2 + ); + if (fittedText) { + ctx.fillStyle = isHighlighted ? 'white' : 'black'; + ctx.fillText(fittedText, x2, y + TEXT_OFFSET_TOP); + } + } + } + } + /* + // x indicates the center of this marker + // y indicates the top of the row + // h indicates the available height in the row + drawOneInstantMarker( + ctx: CanvasRenderingContext2D, + x: CssPixels, + y: CssPixels, + h: CssPixels, + isHighlighted: boolean + ) { + ctx.fillStyle = isHighlighted ? BLUE_60 : '#8ac4ff'; + ctx.strokeStyle = isHighlighted ? BLUE_80 : MARKER_BORDER_COLOR; + + // We're drawing a diamond shape, whose height is h - 2, and width is h / 2. + ctx.beginPath(); + ctx.moveTo(x - h / 4, y + h / 2); + ctx.lineTo(x, y + 1.5); + ctx.lineTo(x + h / 4, y + h / 2); + ctx.lineTo(x, y + h - 1.5); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + } +*/ + drawMarkersForRow( + ctx: CanvasRenderingContext2D, + rowIndex: number, + flowTimingRow: FlowTimingRow, + timeAtViewportLeft: number, + timeAtViewportRightPlusMargin: number, + rangeStart: Milliseconds, + rangeLength: Milliseconds, + viewportLeft: CssPixels, + viewportLength: CssPixels, + rowHeight: CssPixels, + viewportTop: CssPixels, + markerContainerWidth: CssPixels, + marginLeft: CssPixels, + hoveredMarker: HoveredFlowMarker | null + ) { + const { devicePixelRatio } = window; + + const { markers } = flowTimingRow; + + const y: CssPixels = rowIndex * rowHeight - viewportTop; + const h: CssPixels = rowHeight - 1; + + // Track the last drawn marker X position, so that we can avoid overdrawing. + let previousMarkerDrawnAtX: number | null = null; + + for (let i = 0; i < markers.length; i++) { + const startTimestamp = markers.startTime[i]; + const endTimestamp = markers.endTime[i]; + const isInstantMarker = markers.isInstant[i] === 1; + + // Only draw samples that are in bounds. + if ( + endTimestamp >= timeAtViewportLeft && + startTimestamp < timeAtViewportRightPlusMargin + ) { + const startTime: UnitIntervalOfProfileRange = + (startTimestamp - rangeStart) / rangeLength; + const endTime: UnitIntervalOfProfileRange = + (endTimestamp - rangeStart) / rangeLength; + + let x: CssPixels = + ((startTime - viewportLeft) * markerContainerWidth) / viewportLength + + marginLeft; + let w: CssPixels = + ((endTime - startTime) * markerContainerWidth) / viewportLength; + + x = Math.round(x * devicePixelRatio) / devicePixelRatio; + w = Math.round(w * devicePixelRatio) / devicePixelRatio; + + const markerIndex = markers.markerIndex[i]; + const threadIndex = markers.threadIndex[i]; + const isHovered = + hoveredMarker !== null && + threadIndex === hoveredMarker.threadIndex && + markerIndex === hoveredMarker.markerIndex; + + if ( + isInstantMarker || + // Always render non-dot markers and markers that are larger than + // one pixel. + w > 1 || + // Do not render dot markers that occupy the same pixel, as this can take + // a lot of time, and not change the visual display of the chart. + x !== previousMarkerDrawnAtX + ) { + previousMarkerDrawnAtX = x; + this.drawOneMarker( + ctx, + x, + y, + w, + h, + isInstantMarker, + markerIndex, + threadIndex, + isHovered + ); + } + } + } + } + + drawHoveredGapIndicator( + ctx: CanvasRenderingContext2D, + rowIndex: number, + flowTimingRow: FlowTimingRow, + timeAtViewportLeft: number, + timeAtViewportRightPlusMargin: number, + rangeStart: Milliseconds, + rangeLength: Milliseconds, + viewportLeft: CssPixels, + viewportLength: CssPixels, + rowHeight: CssPixels, + viewportTop: CssPixels, + markerContainerWidth: CssPixels, + marginLeft: CssPixels, + markerBeforeHoveredGapAsIndexIntoFlowTimingRowMarkerTable: number + ) { + const { devicePixelRatio } = window; + + const { markers } = flowTimingRow; + + const y: CssPixels = rowIndex * rowHeight - viewportTop; + const h: CssPixels = rowHeight - 1; + + const gapStart = + markers.endTime[ + markerBeforeHoveredGapAsIndexIntoFlowTimingRowMarkerTable + ]; + const gapEnd = + markers.startTime[ + markerBeforeHoveredGapAsIndexIntoFlowTimingRowMarkerTable + 1 + ]; + + // Only draw gap indicators that are in bounds. + if ( + gapEnd >= timeAtViewportLeft && + gapStart < timeAtViewportRightPlusMargin + ) { + const startTime: UnitIntervalOfProfileRange = + (gapStart - rangeStart) / rangeLength; + const endTime: UnitIntervalOfProfileRange = + (gapEnd - rangeStart) / rangeLength; + + let x: CssPixels = + ((startTime - viewportLeft) * markerContainerWidth) / viewportLength + + marginLeft; + let w: CssPixels = + ((endTime - startTime) * markerContainerWidth) / viewportLength; + + x = Math.round(x * devicePixelRatio) / devicePixelRatio; + w = Math.round(w * devicePixelRatio) / devicePixelRatio; + + if (w > 1) { + ctx.fillStyle = '#666'; + ctx.fillRect(x, y + 1 + 4, w, h - 2 - 4 * 2); + } + } + } + + drawRowContents( + ctx: CanvasRenderingContext2D, + _hoveredItem: MarkerIndex | null, + startRow: number, + endRow: number, + hoverInfo: FlowPanelHoverInfo | null + ) { + const { + rangeStart, + rangeEnd, + flowTiming, + rowHeight, + marginLeft, + marginRight, + viewport: { + containerWidth, + containerHeight, + viewportLeft, + viewportRight, + viewportTop, + }, + } = this.props; + + const markerContainerWidth = containerWidth - marginLeft - marginRight; + + const rangeLength: Milliseconds = rangeEnd - rangeStart; + const viewportLength: UnitIntervalOfProfileRange = + viewportRight - viewportLeft; + + // Decide which samples to actually draw + const timeAtViewportLeft: Milliseconds = + rangeStart + rangeLength * viewportLeft; + const timeAtViewportRightPlusMargin: Milliseconds = + rangeStart + + rangeLength * viewportRight + + // This represents the amount of seconds in the right margin: + marginRight * ((viewportLength * rangeLength) / markerContainerWidth); + + // We'll restore the context at the end, so that the clip region will be + // removed. + ctx.save(); + // The clip operation forbids drawing in the label zone. + ctx.beginPath(); + ctx.rect(marginLeft, 0, markerContainerWidth, containerHeight); + ctx.clip(); + + // Only draw the stack frames that are vertically within view. + for (let rowIndex = startRow; rowIndex < endRow; rowIndex++) { + // Get the timing information for a row of stack frames. + const flowTimingRow = flowTiming.rows[rowIndex]; + const flowIndex = flowTimingRow.flowIndex; + this.drawFlowRectangle( + ctx, + rowIndex, + timeAtViewportLeft, + timeAtViewportRightPlusMargin, + rangeStart, + rangeLength, + viewportLeft, + markerContainerWidth, + viewportLength, + marginLeft + ); + const hoveredItemInThisRow = + hoverInfo !== null && + hoverInfo.flowIndex === flowIndex && + hoverInfo.hoveredItem !== null + ? hoverInfo.hoveredItem + : null; + const hoveredMarkerInThisRow = + hoveredItemInThisRow !== null && + hoveredItemInThisRow.type === 'SINGLE_MARKER' + ? hoveredItemInThisRow.hoveredMarker + : null; + this.drawMarkersForRow( + ctx, + rowIndex, + flowTimingRow, + timeAtViewportLeft, + timeAtViewportRightPlusMargin, + rangeStart, + rangeLength, + viewportLeft, + viewportLength, + rowHeight, + viewportTop, + markerContainerWidth, + marginLeft, + hoveredMarkerInThisRow + ); + if ( + hoveredItemInThisRow !== null && + hoveredItemInThisRow.type === 'BETWEEN_MARKERS' + ) { + this.drawHoveredGapIndicator( + ctx, + rowIndex, + flowTimingRow, + timeAtViewportLeft, + timeAtViewportRightPlusMargin, + rangeStart, + rangeLength, + viewportLeft, + viewportLength, + rowHeight, + viewportTop, + markerContainerWidth, + marginLeft, + hoveredItemInThisRow.markerBeforeHoveredGapAsIndexIntoFlowTimingRowMarkerTable + ); + } + } + + ctx.restore(); + } + + /** + * Lazily create the text measurement tool, as a valid 2d rendering context must + * exist before it is created. + */ + _getTextMeasurement(ctx: CanvasRenderingContext2D): TextMeasurement { + if (!this._textMeasurement) { + this._textMeasurement = new TextMeasurement(ctx); + } + return this._textMeasurement; + } + + drawSeparatorsAndLabels( + ctx: CanvasRenderingContext2D, + startRow: number, + endRow: number + ) { + const { + flowTiming, + rowHeight, + marginLeft, + marginRight, + viewport: { viewportTop, containerWidth, containerHeight }, + } = this.props; + + const usefulContainerWidth = containerWidth - marginRight; + + // Draw separators + ctx.fillStyle = GREY_20; + ctx.fillRect(marginLeft - 1, 0, 1, containerHeight); + for (let rowIndex = startRow; rowIndex < endRow; rowIndex++) { + // `- 1` at the end, because the top separator is not drawn in the canvas, + // it's drawn using CSS' border property. And canvas positioning is 0-based. + const y = (rowIndex + 1) * rowHeight - viewportTop - 1; + ctx.fillRect(0, y, usefulContainerWidth, 1); + } + + const textMeasurement = this._getTextMeasurement(ctx); + + // Draw the marker names in the left margin. + ctx.fillStyle = '#000000'; + for (let rowIndex = startRow; rowIndex < endRow; rowIndex++) { + const markerTimingRow = flowTiming.rows[rowIndex]; + // Draw the marker name. + const { label } = markerTimingRow; + + const y = rowIndex * rowHeight - viewportTop; + + // Even when it's on active tab view, have a hard cap on the text length. + const fittedText = textMeasurement.getFittedText( + label, + TIMELINE_MARGIN_LEFT - LABEL_PADDING + ); + + ctx.fillText(fittedText, LABEL_PADDING, y + TEXT_OFFSET_TOP); + } + } + + drawArrows( + ctx: CanvasRenderingContext2D, + arrows: FlowTimingArrow[], + startRow: number, + endRow: number + ) { + const { + rangeStart, + rangeEnd, + rowHeight, + marginLeft, + marginRight, + viewport: { + containerWidth, + containerHeight, + viewportLeft, + viewportRight, + viewportTop, + }, + } = this.props; + + const markerContainerWidth = containerWidth - marginLeft - marginRight; + + const rangeLength: Milliseconds = rangeEnd - rangeStart; + const viewportLength: UnitIntervalOfProfileRange = + viewportRight - viewportLeft; + + // Decide which samples to actually draw + const timeAtViewportLeft: Milliseconds = + rangeStart + rangeLength * viewportLeft; + const timeAtViewportRightPlusMargin: Milliseconds = + rangeStart + + rangeLength * viewportRight + + // This represents the amount of seconds in the right margin: + marginRight * ((viewportLength * rangeLength) / markerContainerWidth); + + // We'll restore the context at the end, so that the clip region will be + // removed. + ctx.save(); + // The clip operation forbids drawing in the label zone. + ctx.beginPath(); + ctx.rect(marginLeft, 0, markerContainerWidth, containerHeight); + ctx.clip(); + + ctx.lineCap = 'round'; + ctx.strokeStyle = 'black'; + + for (const arrow of arrows) { + const { time, rowIndexesFrom, rowIndexesTo, minRowIndex, maxRowIndex } = + arrow; + if ( + maxRowIndex < startRow || + minRowIndex > endRow || + time < timeAtViewportLeft || + time > timeAtViewportRightPlusMargin + ) { + continue; + } + const minY: CssPixels = + minRowIndex * rowHeight - viewportTop + rowHeight / 2 + 2; + const maxY: CssPixels = + maxRowIndex * rowHeight - viewportTop + rowHeight / 2 - 2; + const timeAsUnit: UnitIntervalOfProfileRange = + (time - rangeStart) / rangeLength; + const x: CssPixels = + ((timeAsUnit - viewportLeft) * markerContainerWidth) / viewportLength + + marginLeft; + + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(x, minY); + ctx.lineTo(x, maxY); + ctx.stroke(); + + const minFrom = Math.min(...rowIndexesFrom); + const maxFrom = Math.max(...rowIndexesFrom); + for (const rowIndex of rowIndexesFrom) { + const y = rowIndex * rowHeight - viewportTop + rowHeight / 2; + ctx.lineWidth = 3; + ctx.beginPath(); + ctx.moveTo(x, y); + ctx.lineTo(x, y); + ctx.stroke(); + } + for (const rowIndex of rowIndexesTo) { + const y = rowIndex * rowHeight - viewportTop + rowHeight / 2; + if (minFrom < rowIndex) { + // Draw arrow from top to bottom (aimed at rowIndex) + ctx.beginPath(); + ctx.moveTo(x - 3.5, y - 8); + ctx.lineTo(x + 3.5, y - 8); + ctx.lineTo(x, y); + ctx.closePath(); + ctx.fill(); + } + if (maxFrom > rowIndex) { + // Draw arrow from bottom to top (aimed at rowIndex) + ctx.beginPath(); + ctx.moveTo(x + 3.5, y + 8); + ctx.lineTo(x - 3.5, y + 8); + ctx.lineTo(x, y); + ctx.closePath(); + ctx.fill(); + } + } + } + ctx.restore(); + } + + hitTest = (x: CssPixels, y: CssPixels): FlowPanelHoverInfo | null => { + const { + rangeStart, + rangeEnd, + flowTiming, + rowHeight, + marginLeft, + marginRight, + viewport: { viewportLeft, viewportRight, viewportTop, containerWidth }, + } = this.props; + + // Note: we may want to increase this value to hit markers that are farther. + const dotRadius: CssPixels = MARKER_DOT_RADIUS * rowHeight; + if (x < marginLeft - dotRadius) { + return null; + } + + const markerContainerWidth = containerWidth - marginLeft - marginRight; + + const rangeLength: Milliseconds = rangeEnd - rangeStart; + + // Reminder: this is a value between 0 and 1, and represents a percentage of + // the full time range. + const viewportLength: UnitIntervalOfProfileRange = + viewportRight - viewportLeft; + + // This is the x position in terms of unit interval (so, between 0 and 1). + const xInUnitInterval: UnitIntervalOfProfileRange = + viewportLeft + viewportLength * ((x - marginLeft) / markerContainerWidth); + + const dotRadiusInTime = + (dotRadius / markerContainerWidth) * viewportLength * rangeLength; + + const xInTime: Milliseconds = rangeStart + xInUnitInterval * rangeLength; + const rowIndex = Math.floor((y + viewportTop) / rowHeight); + + if (rowIndex < 0 || rowIndex >= flowTiming.rows.length) { + return null; + } + + const row = flowTiming.rows[rowIndex]; + const flowIndex = row.flowIndex; + const markerTiming = row.markers; + + if ( + !markerTiming || + typeof markerTiming === 'string' || + !markerTiming.length + ) { + return null; + } + + // This is a small utility function to define if some marker timing is in + // our hit test range. + const isMarkerTimingInDotRadius = (index: number) => + markerTiming.startTime[index] < xInTime + dotRadiusInTime && + markerTiming.endTime[index] > xInTime - dotRadiusInTime; + + // A markerTiming line is ordered. + // 1. Let's find a marker reasonably close to our mouse cursor. + // The result of this bisection gives the first marker that starts _after_ + // our mouse cursor. Our result will be either this marker, or the previous + // one. + const nextStartIndex = bisectionRight(markerTiming.startTime, xInTime); + + if (nextStartIndex > 0 && nextStartIndex < markerTiming.length) { + // 2. This is the common case: 2 markers are candidates. Then we measure + // the distance between them and the mouse cursor and chose the smallest + // distance. + const prevStartIndex = nextStartIndex - 1; + + // Note that these values can be negative if the cursor is _inside_ a + // marker. There should be one at most in this case, and we'll want it. So + // NO Math.abs here. + const distanceToNext = markerTiming.startTime[nextStartIndex] - xInTime; + const distanceToPrev = xInTime - markerTiming.endTime[prevStartIndex]; + + const closest = + distanceToPrev < distanceToNext ? prevStartIndex : nextStartIndex; + + // 3. When we found the closest, we still have to check if it's in close + // enough! + if (isMarkerTimingInDotRadius(closest)) { + return { + rowIndex, + flowIndex, + hoveredItem: { + type: 'SINGLE_MARKER', + hoveredMarker: { + markerIndex: markerTiming.markerIndex[closest], + flowMarkerIndex: markerTiming.flowMarkerIndex[closest], + threadIndex: markerTiming.threadIndex[closest], + indexInFlowMarkers: closest, + }, + }, + }; + } + + // The cursor is between two markers. + return { + rowIndex, + flowIndex, + hoveredItem: { + type: 'BETWEEN_MARKERS', + markerBeforeHoveredGapAsIndexIntoFlowTimingRowMarkerTable: + prevStartIndex, + markerBeforeHoveredGap: { + markerIndex: markerTiming.markerIndex[prevStartIndex], + flowMarkerIndex: markerTiming.flowMarkerIndex[prevStartIndex], + threadIndex: markerTiming.threadIndex[prevStartIndex], + indexInFlowMarkers: prevStartIndex, + }, + markerAfterHoveredGap: { + markerIndex: markerTiming.markerIndex[nextStartIndex], + flowMarkerIndex: markerTiming.flowMarkerIndex[nextStartIndex], + threadIndex: markerTiming.threadIndex[nextStartIndex], + indexInFlowMarkers: nextStartIndex, + }, + }, + }; + } else if (nextStartIndex === 0) { + // 4. Special case 1: the mouse cursor is at the left of all markers in + // this line. Then, we have only 1 candidate, we can check if it's inside + // our hit test range right away. + if (isMarkerTimingInDotRadius(nextStartIndex)) { + return { + rowIndex, + flowIndex, + hoveredItem: { + type: 'SINGLE_MARKER', + hoveredMarker: { + markerIndex: markerTiming.markerIndex[nextStartIndex], + flowMarkerIndex: markerTiming.flowMarkerIndex[nextStartIndex], + threadIndex: markerTiming.threadIndex[nextStartIndex], + indexInFlowMarkers: nextStartIndex, + }, + }, + }; + } + } else { + // 5. Special case 2: the mouse cursor is at the right of all markers in + // this line. Then we only have 1 candidate as well, let's check if it's + // inside our hit test range. + if (isMarkerTimingInDotRadius(nextStartIndex - 1)) { + return { + rowIndex, + flowIndex, + hoveredItem: { + type: 'SINGLE_MARKER', + hoveredMarker: { + markerIndex: markerTiming.markerIndex[nextStartIndex - 1], + flowMarkerIndex: markerTiming.flowMarkerIndex[nextStartIndex - 1], + threadIndex: markerTiming.threadIndex[nextStartIndex - 1], + indexInFlowMarkers: nextStartIndex - 1, + }, + }, + }; + } + } + + return { + rowIndex, + flowIndex, + hoveredItem: null, + }; + }; + + onMouseMove = (event: { nativeEvent: MouseEvent }) => { + const { + changeMouseTimePosition, + rangeStart, + rangeEnd, + marginLeft, + marginRight, + viewport: { viewportLeft, viewportRight, containerWidth }, + } = this.props; + const viewportLength: UnitIntervalOfProfileRange = + viewportRight - viewportLeft; + const markerContainerWidth = containerWidth - marginLeft - marginRight; + // This is the x position in terms of unit interval (so, between 0 and 1). + const xInUnitInterval: UnitIntervalOfProfileRange = + viewportLeft + + viewportLength * + ((event.nativeEvent.offsetX - marginLeft) / markerContainerWidth); + + if (xInUnitInterval < 0 || xInUnitInterval > 1) { + changeMouseTimePosition(null); + } else { + const rangeLength: Milliseconds = rangeEnd - rangeStart; + const xInTime: Milliseconds = rangeStart + xInUnitInterval * rangeLength; + changeMouseTimePosition(xInTime); + } + }; + + onMouseLeave = () => { + this.props.changeMouseTimePosition(null); + }; + + onDoubleClickMarker = (_hoveredItems: FlowPanelHoverInfo | null) => {}; + + onSelectItem = (hoveredItems: FlowPanelHoverInfo | null) => { + const flowIndex = hoveredItems === null ? null : hoveredItems.flowIndex; + if (flowIndex === null) { + return; + } + + const { changeActiveFlows } = this.props; + changeActiveFlows([flowIndex]); + }; + + onRightClickMarker = (_hoveredItems: FlowPanelHoverInfo | null) => { + // const markerIndex = hoveredItems === null ? null : hoveredItems.markerIndex; + // const { changeRightClickedMarker, threadsKey } = this.props; + // changeRightClickedMarker(threadsKey, markerIndex); + }; + + getHoveredMarkerInfo = (hoverInfo: FlowPanelHoverInfo): React.ReactNode => { + if (!this.props.shouldDisplayTooltips() || hoverInfo.hoveredItem === null) { + return null; + } + + const { hoveredItem } = hoverInfo; + + switch (hoveredItem.type) { + case 'SINGLE_MARKER': { + const { threadIndex, markerIndex } = hoveredItem.hoveredMarker; + + const marker = ensureExists( + this.props.fullMarkerListPerThread[threadIndex][markerIndex] + ); + return ( + + ); + } + case 'BETWEEN_MARKERS': { + const { markerBeforeHoveredGap, markerAfterHoveredGap } = hoveredItem; + const beforeGapMarker = ensureExists( + this.props.fullMarkerListPerThread[ + markerBeforeHoveredGap.threadIndex + ][markerBeforeHoveredGap.markerIndex] + ); + const afterGapMarker = ensureExists( + this.props.fullMarkerListPerThread[markerAfterHoveredGap.threadIndex][ + markerAfterHoveredGap.markerIndex + ] + ); + return ( + + ); + } + default: { + throw assertExhaustiveCheck( + hoveredItem, + 'Unhandled HoveredFlowPanelItem type.' + ); + } + } + }; + + override render() { + const { containerWidth, containerHeight, isDragging } = this.props.viewport; + + return ( + + ); + } +} + +export const FlowPanelCanvas = withChartViewport(FlowPanelCanvasImpl); diff --git a/src/components/flow-panel/index.css b/src/components/flow-panel/index.css new file mode 100644 index 0000000000..7451d9d019 --- /dev/null +++ b/src/components/flow-panel/index.css @@ -0,0 +1,13 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +.flowPanel { + display: flex; + flex: 1; + flex-flow: column nowrap; +} + +.markerChartCanvas { + border-top: 1px solid var(--grey-30); +} diff --git a/src/components/flow-panel/index.tsx b/src/components/flow-panel/index.tsx new file mode 100644 index 0000000000..d4ee49750d --- /dev/null +++ b/src/components/flow-panel/index.tsx @@ -0,0 +1,179 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// @flow +import * as React from 'react'; +import { + TIMELINE_MARGIN_LEFT, + TIMELINE_MARGIN_RIGHT, +} from 'firefox-profiler/app-logic/constants'; +import explicitConnect from 'firefox-profiler/utils/connect'; +import { FlowPanelCanvas } from './Canvas'; + +import { + getCommittedRange, + getPreviewSelection, +} from 'firefox-profiler/selectors/profile'; +import { + getFullMarkerListPerThread, + getMarkerChartLabelGetterPerThread, + getFlowTiming, +} from 'firefox-profiler/selectors/flow'; +import { + updatePreviewSelection, + changeMouseTimePosition, + changeActiveFlows, +} from 'firefox-profiler/actions/profile-view'; +import { ContextMenuTrigger } from 'firefox-profiler/components/shared/ContextMenuTrigger'; + +import type { + Marker, + MarkerIndex, + FlowTiming, + UnitIntervalOfProfileRange, + StartEndRange, + PreviewSelection, +} from 'firefox-profiler/types'; + +import type { ConnectedProps } from 'firefox-profiler/utils/connect'; + +import './index.css'; + +const ROW_HEIGHT = 16; + +type DispatchProps = { + updatePreviewSelection: typeof updatePreviewSelection; + changeMouseTimePosition: typeof changeMouseTimePosition; + changeActiveFlows: typeof changeActiveFlows; +}; + +type StateProps = { + fullMarkerListPerThread: Marker[][]; + markerLabelGetterPerThread: Array<(marker: MarkerIndex) => string>; + flowTiming: FlowTiming; + timeRange: StartEndRange; + previewSelection: PreviewSelection | null; +}; + +type Props = ConnectedProps<{}, StateProps, DispatchProps>; + +class FlowPanelImpl extends React.PureComponent { + _viewport: HTMLDivElement | null = null; + + /** + * Determine the maximum zoom of the viewport. + */ + getMaximumZoom(): UnitIntervalOfProfileRange { + const { + timeRange: { start, end }, + } = this.props; + + // This is set to a very small value, that represents 1ns. We can't set it + // to zero unless we revamp how ranges are handled in the app to prevent + // less-than-1ns ranges, otherwise we can get stuck at a "0" zoom. + const ONE_NS = 1e-6; + return ONE_NS / (end - start); + } + + _shouldDisplayTooltips = () => true; + + _takeViewportRef = (viewport: HTMLDivElement | null) => { + this._viewport = viewport; + }; + + _focusViewport = () => { + if (this._viewport) { + this._viewport.focus(); + } + }; + + override componentDidMount() { + this._focusViewport(); + } + + override render() { + const { + timeRange, + flowTiming, + fullMarkerListPerThread, + markerLabelGetterPerThread, + previewSelection, + updatePreviewSelection, + changeMouseTimePosition, + changeActiveFlows, + } = this.props; + + // The viewport needs to know about the height of what it's drawing, calculate + // that here at the top level component. + const rowCount = flowTiming.rows.length; + const maxViewportHeight = rowCount * ROW_HEIGHT; + + return ( +
+ {rowCount === 0 ? null : ( + + + + )} +
+ ); + } +} + +// This function is given the FlowPanelCanvas's chartProps. +function viewportNeedsUpdate( + prevProps: { flowTiming: FlowTiming }, + newProps: { flowTiming: FlowTiming } +) { + return prevProps.flowTiming !== newProps.flowTiming; +} + +export const FlowPanel = explicitConnect<{}, StateProps, DispatchProps>({ + mapStateToProps: (state) => { + const flowTiming = getFlowTiming(state); + return { + fullMarkerListPerThread: getFullMarkerListPerThread(state), + markerLabelGetterPerThread: getMarkerChartLabelGetterPerThread(state), + flowTiming, + timeRange: getCommittedRange(state), + previewSelection: getPreviewSelection(state), + }; + }, + mapDispatchToProps: { + updatePreviewSelection, + changeMouseTimePosition, + changeActiveFlows, + }, + component: FlowPanelImpl, +}); diff --git a/src/components/marker-chart-tab/index.css b/src/components/marker-chart-tab/index.css new file mode 100644 index 0000000000..c29d6acb16 --- /dev/null +++ b/src/components/marker-chart-tab/index.css @@ -0,0 +1,7 @@ +.markerChartTabContainer { + position: relative; + display: flex; + min-height: 0; + flex: 1; + flex-flow: column nowrap; +} diff --git a/src/components/marker-chart-tab/index.tsx b/src/components/marker-chart-tab/index.tsx new file mode 100644 index 0000000000..d9d964b80a --- /dev/null +++ b/src/components/marker-chart-tab/index.tsx @@ -0,0 +1,30 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { MarkerChart } from '../marker-chart'; +import { FlowPanel } from '../flow-panel'; + +import './index.css'; +import { ResizableWithSplitter } from '../shared/ResizableWithSplitter'; + +export function MarkerChartTab() { + return ( +
+ + + + +
+ ); +} diff --git a/src/components/marker-chart/Canvas.tsx b/src/components/marker-chart/Canvas.tsx index 491a81e5fb..54ad1deda0 100644 --- a/src/components/marker-chart/Canvas.tsx +++ b/src/components/marker-chart/Canvas.tsx @@ -28,6 +28,7 @@ import type { changeMouseTimePosition, changeSelectedMarker, updateBottomBoxContentsAndMaybeOpen, + activateFlowsForMarker, } from 'firefox-profiler/actions/profile-view'; type UpdatePreviewSelection = typeof updatePreviewSelection; @@ -91,6 +92,9 @@ type OwnProps = { readonly changeMouseTimePosition: ChangeMouseTimePosition; readonly changeSelectedMarker: ChangeSelectedMarker; readonly changeRightClickedMarker: ChangeRightClickedMarker; + readonly activateFlowsForMarker: WrapFunctionInDispatch< + typeof activateFlowsForMarker + >; readonly marginLeft: CssPixels; readonly marginRight: CssPixels; readonly selectedMarkerIndex: MarkerIndex | null; @@ -389,7 +393,8 @@ class MarkerChartCanvasImpl extends React.PureComponent { isHighlighted: boolean = false ) { if (isInstantMarker) { - this.drawOneInstantMarker(ctx, x, y, h, markerIndex, isHighlighted); + // this.drawOneInstantMarker(ctx, x, y, h, markerIndex, isHighlighted); + this.drawOneIntervalMarker(ctx, x, y, 1, h, markerIndex, isHighlighted); } else { this.drawOneIntervalMarker(ctx, x, y, w, h, markerIndex, isHighlighted); } @@ -465,35 +470,6 @@ class MarkerChartCanvasImpl extends React.PureComponent { } } - // x indicates the center of this marker - // y indicates the top of the row - // h indicates the available height in the row - drawOneInstantMarker( - ctx: CanvasRenderingContext2D, - x: CssPixels, - y: CssPixels, - h: CssPixels, - markerIndex: MarkerIndex, - isHighlighted: boolean - ) { - const { fillColor, strokeColor } = this._getMarkerColors( - markerIndex, - isHighlighted - ); - ctx.fillStyle = fillColor; - ctx.strokeStyle = strokeColor; - - // We're drawing a diamond shape, whose height is h - 2, and width is h / 2. - ctx.beginPath(); - ctx.moveTo(x - h / 4, y + h / 2); - ctx.lineTo(x, y + 1.5); - ctx.lineTo(x + h / 4, y + h / 2); - ctx.lineTo(x, y + h - 1.5); - ctx.closePath(); - ctx.fill(); - ctx.stroke(); - } - drawMarkers( ctx: CanvasRenderingContext2D, hoveredItem: MarkerIndex | null, @@ -948,8 +924,15 @@ class MarkerChartCanvasImpl extends React.PureComponent { }; onSelectItem = (markerIndex: MarkerIndex | null) => { - const { changeSelectedMarker, threadsKey } = this.props; + const { changeSelectedMarker, activateFlowsForMarker, threadsKey } = + this.props; changeSelectedMarker(threadsKey, markerIndex, { source: 'pointer' }); + console.log({ threadsKey, markerIndex }); + if (typeof threadsKey === 'number' && markerIndex !== null) { + console.log('hello'); + const what = activateFlowsForMarker(threadsKey, markerIndex); + console.log({ what }); + } }; onRightClickMarker = (markerIndex: MarkerIndex | null) => { diff --git a/src/components/marker-chart/index.tsx b/src/components/marker-chart/index.tsx index 93bb9a1e96..6a755c2dad 100644 --- a/src/components/marker-chart/index.tsx +++ b/src/components/marker-chart/index.tsx @@ -27,6 +27,7 @@ import { changeMouseTimePosition, changeSelectedMarker, updateBottomBoxContentsAndMaybeOpen, + activateFlowsForMarker, } from 'firefox-profiler/actions/profile-view'; import { ContextMenuTrigger } from 'firefox-profiler/components/shared/ContextMenuTrigger'; @@ -55,6 +56,7 @@ type DispatchProps = { readonly changeMouseTimePosition: typeof changeMouseTimePosition; readonly changeSelectedMarker: typeof changeSelectedMarker; readonly updateBottomBoxContentsAndMaybeOpen: typeof updateBottomBoxContentsAndMaybeOpen; + readonly activateFlowsForMarker: typeof activateFlowsForMarker; }; type StateProps = { @@ -123,6 +125,7 @@ class MarkerChartImpl extends React.PureComponent { updatePreviewSelection, changeMouseTimePosition, changeRightClickedMarker, + activateFlowsForMarker, rightClickedMarkerIndex, selectedMarkerIndex, changeSelectedMarker, @@ -133,12 +136,7 @@ class MarkerChartImpl extends React.PureComponent { const maxViewportHeight = maxMarkerRows * ROW_HEIGHT; return ( -
+
{maxMarkerRows === 0 ? ( @@ -170,6 +168,7 @@ class MarkerChartImpl extends React.PureComponent { updatePreviewSelection, changeMouseTimePosition, changeRightClickedMarker, + activateFlowsForMarker, rangeStart: timeRange.start, rangeEnd: timeRange.end, rowHeight: ROW_HEIGHT, @@ -229,6 +228,7 @@ export const MarkerChart = explicitConnect<{}, StateProps, DispatchProps>({ changeRightClickedMarker, changeSelectedMarker, updateBottomBoxContentsAndMaybeOpen, + activateFlowsForMarker, }, component: MarkerChartImpl, }); diff --git a/src/components/tooltip/FlowGap.tsx b/src/components/tooltip/FlowGap.tsx new file mode 100644 index 0000000000..0c00cf7b07 --- /dev/null +++ b/src/components/tooltip/FlowGap.tsx @@ -0,0 +1,110 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// @flow + +import * as React from 'react'; +import classNames from 'classnames'; +import explicitConnect from 'firefox-profiler/utils/connect'; +import { getThreadSelectorsFromThreadsKey } from 'firefox-profiler/selectors'; +import { formatTimestamp } from 'firefox-profiler/utils/format-numbers'; + +import { TooltipDetails, TooltipDetail } from './TooltipDetails'; + +import type { Marker, MarkerIndex } from 'firefox-profiler/types'; + +import type { ConnectedProps } from 'firefox-profiler/utils/connect'; +import './Marker.css'; + +type OwnProps = { + beforeGapMarkerIndex: MarkerIndex; + beforeGapMarker: Marker; + beforeGapThreadIndex: number; + afterGapMarkerIndex: MarkerIndex; + afterGapMarker: Marker; + afterGapThreadIndex: number; + className?: string; +}; + +type StateProps = { + beforeGapThreadName?: string; + afterGapThreadName?: string; + beforeGapGetMarkerLabel: (marker: MarkerIndex) => string; + afterGapGetMarkerLabel: (marker: MarkerIndex) => string; +}; + +type Props = ConnectedProps; + +/** + * This component combines Marker Schema, and custom handling to generate tooltips + * for markers. + */ +class FlowGapTooltipContents extends React.PureComponent { + _maybeRenderMarkerDuration() { + const { beforeGapMarker, afterGapMarker } = this.props; + const gapStart = beforeGapMarker.end ?? beforeGapMarker.start; + const gapEnd = afterGapMarker.start; + const duration = formatTimestamp(gapEnd - gapStart, 3, 1); + return
{duration}
; + } + + override render() { + const { className } = this.props; + const { + beforeGapMarkerIndex, + beforeGapGetMarkerLabel, + afterGapMarkerIndex, + afterGapGetMarkerLabel, + beforeGapThreadName, + afterGapThreadName, + } = this.props; + const beforeLabel = beforeGapGetMarkerLabel(beforeGapMarkerIndex); + const afterLabel = afterGapGetMarkerLabel(afterGapMarkerIndex); + + return ( +
+
+
+ {this._maybeRenderMarkerDuration()} +
Gap
+
+
+ + + {beforeLabel} + + + {beforeGapThreadName} + + + {afterLabel} + + + {afterGapThreadName} + + +
+ ); + } +} + +export const FlowGapTooltip = explicitConnect({ + mapStateToProps: (state, props) => { + const beforeGapSelectors = getThreadSelectorsFromThreadsKey( + props.beforeGapThreadIndex + ); + const afterGapSelectors = getThreadSelectorsFromThreadsKey( + props.afterGapThreadIndex + ); + return { + beforeGapThreadName: beforeGapSelectors.getFriendlyThreadName(state), + afterGapThreadName: afterGapSelectors.getFriendlyThreadName(state), + beforeGapGetMarkerLabel: + beforeGapSelectors.getMarkerTooltipLabelGetter(state), + afterGapGetMarkerLabel: + afterGapSelectors.getMarkerTooltipLabelGetter(state), + }; + }, + component: FlowGapTooltipContents, +}); diff --git a/src/profile-logic/marker-data.ts b/src/profile-logic/marker-data.ts index 682676e10a..31a91c80cf 100644 --- a/src/profile-logic/marker-data.ts +++ b/src/profile-logic/marker-data.ts @@ -59,6 +59,16 @@ import type { Profile, RemoveProfileInformation, Milliseconds, + FlowMarker, + FlowSchemasByName, + ProfileFlowInfo, + FlowTiming, + IndexIntoFlowTable, + ConnectedFlowInfo, + FlowTimingRow, + FlowTimingRowType, + FlowTimingRowMarkerTable, + FlowTimingArrow, } from 'firefox-profiler/types'; /** @@ -1765,59 +1775,6 @@ export function formatLogStatement( return `${timestampStr} - [${processName} ${pid}: ${threadName}]: ${prefix}${data.module} ${data.name.trim()}`; } - -export type GlobalFlowMarkerHandle = { - threadIndex: number, - flowMarkerIndex: number, -}; - -// An index into the global flow table. -type IndexIntoFlowTable = number; - -export type Flow = { - id: string, - startTime: Milliseconds, - endTime: Milliseconds | null, - // All markers which mention this flow, ordered by start time. - flowMarkers: GlobalFlowMarkerHandle[], -}; - -export type ConnectedFlowInfo = { - // Flows whose marker set has a non-empty intersection with our marker set. - directlyConnectedFlows: IndexIntoFlowTable[], - // Flows which have at least one marker in their marker set was a stack-based - // marker which was already running higher up on the stack when at least one - // of our stack-based or instant markers was running on the same thread. - // All flows in our incomingContextFlows set have this flow in their - // outgoingContextFlows set. - incomingContextFlows: IndexIntoFlowTable[], - // Flows which have at least one stack-based or instant marker in their marker - // set which was running when one of the stack-based markers in our set was - // running higher up on the same thread's stack. - // All flows in our outgoingContextFlows set have this flow in their - // incomingContextFlows set. - outgoingContextFlows: IndexIntoFlowTable[], -}; - -type FlowIDAndTerminating = { - flowID: string, - isTerminating: boolean, -}; - -export type FlowMarker = { - markerIndex: number, - time: Milliseconds, - // The index of the closest stack-based interval flow marker that encompasses - // this marker. ("Closest" means "with the most recent start time".) - // If non-null, parentContextFlowMarker is lower the index of this flow marker, - // i.e. this can only point "backwards" within the thread's flow markers array. - parentContextFlowMarker: number | null, - // The indexes of flow markers which have this flow marker as their parentContextFlowMarker. - // All indexes in this array after the index of this flow marker. - childContextFlowMarkers: number[], - flowIDs: FlowIDAndTerminating[], -}; - class MinHeap { _keys: number[] = []; _values: V[] = []; @@ -1857,18 +1814,6 @@ class MinHeap { } } -export type FlowFieldDescriptor = { - key: string, - isTerminating: boolean, -}; - -export type FlowSchema = { - flowFields: FlowFieldDescriptor[], - isStackBased: boolean, -}; - -export type FlowSchemasByName = Map; - export function computeFlowSchemasByName( markerSchemas: MarkerSchema[] ): FlowSchemasByName { @@ -1920,11 +1865,12 @@ export function computeFlowMarkers( continue; } - const time = marker.start; + const startTime = marker.start; + const endTime = marker.end; while ( currentContextEndTimes.length !== 0 && - currentContextEndTimes[currentContextEndTimes.length - 1] < time + currentContextEndTimes[currentContextEndTimes.length - 1] < startTime ) { currentContextEndTimes.pop(); currentContextFlowMarkers.pop(); @@ -1951,7 +1897,8 @@ export function computeFlowMarkers( parentContextFlowMarker, childContextFlowMarkers: [], flowIDs, - time, + startTime, + endTime, markerIndex, }); if (flowSchema.isStackBased || marker.end === null) { @@ -1969,19 +1916,11 @@ export function computeFlowMarkers( return flowMarkers; } -export type ProfileFlowInfo = { - flowTable: Flow[], - flowsByID: Map, - flowMarkersPerThread: FlowMarker[][], - flowMarkerFlowsPerThread: IndexIntoFlowTable[][][], - flowSchemasByName: FlowSchemasByName, -}; - export function computeProfileFlowInfo( fullMarkerListPerThread: Marker[][], _threads: RawThread[], markerSchemas: MarkerSchema[], - shared: RawProfileSharedData, + shared: RawProfileSharedData ): ProfileFlowInfo { const flowSchemasByName = computeFlowSchemasByName(markerSchemas); const { stringArray } = shared; @@ -1993,18 +1932,22 @@ export function computeProfileFlowInfo( ); const threadCount = flowMarkersPerThread.length; - const nextEntryHeap = new MinHeap<{threadIndex: number, nextIndex: number}>(); + const nextEntryHeap = new MinHeap<{ + threadIndex: number; + nextIndex: number; + }>(); for (let threadIndex = 0; threadIndex < threadCount; threadIndex++) { const flowMarkers = flowMarkersPerThread[threadIndex]; if (flowMarkers.length !== 0) { - nextEntryHeap.insert(flowMarkers[0].time, { + nextEntryHeap.insert(flowMarkers[0].startTime, { threadIndex, nextIndex: 0, }); } } - const flowMarkerFlowsPerThread: IndexIntoFlowTable[][][] = flowMarkersPerThread.map(() => []); + const flowMarkerFlowsPerThread: IndexIntoFlowTable[][][] = + flowMarkersPerThread.map(() => []); const flowTable = []; const currentActiveFlows = new Map(); @@ -2020,9 +1963,10 @@ export function computeProfileFlowInfo( const { threadIndex, nextIndex } = nextEntry; const flowMarkerIndex = nextIndex; const flowMarkers = flowMarkersPerThread[threadIndex]; - const flowReference = flowMarkers[nextIndex]; + const flowMarker = flowMarkers[nextIndex]; - const { flowIDs, time } = flowReference; + const { markerIndex, flowIDs } = flowMarker; + const { start, end } = fullMarkerListPerThread[threadIndex][markerIndex]; const flowMarkerHandle = { threadIndex, flowMarkerIndex }; const flowsForThisFlowMarker = []; for (const { flowID, isTerminating } of flowIDs) { @@ -2031,8 +1975,8 @@ export function computeProfileFlowInfo( flowIndex = flowTable.length; flowTable.push({ id: flowID, - startTime: time, - endTime: time, + startTime: start, + endTime: end ?? start, flowMarkers: [flowMarkerHandle], }); if (!isTerminating) { @@ -2047,7 +1991,7 @@ export function computeProfileFlowInfo( } else { const flow = flowTable[flowIndex]; flow.flowMarkers.push(flowMarkerHandle); - flow.endTime = time; + flow.endTime = end ?? start; if (isTerminating) { currentActiveFlows.delete(flowID); } @@ -2061,7 +2005,7 @@ export function computeProfileFlowInfo( const newNextIndex = nextIndex + 1; if (newNextIndex < flowMarkers.length) { nextEntry.nextIndex = newNextIndex; - nextEntryHeap.reorder(handle, flowMarkers[newNextIndex].time); + nextEntryHeap.reorder(handle, flowMarkers[newNextIndex].startTime); } else { nextEntryHeap.delete(handle); } @@ -2152,11 +2096,10 @@ export function lookupFlow( return candidateFlows[index]; } -export function computeMarkerFlowsForConsole( +export function computeMarkerFlows( threadIndex: number, markerIndex: MarkerIndex, profileFlowInfo: ProfileFlowInfo, - _threads: RawThread[], fullMarkerListPerThread: Marker[][], stringTable: StringTable ): IndexIntoFlowTable[] | null { @@ -2224,11 +2167,10 @@ export function printMarkerFlows( fullMarkerListPerThread: Marker[][], stringTable: StringTable ) { - const markerFlows = computeMarkerFlowsForConsole( + const markerFlows = computeMarkerFlows( markerThreadIndex, markerIndex, profileFlowInfo, - threads, fullMarkerListPerThread, stringTable ); @@ -2274,7 +2216,7 @@ export function printFlow( const thread = threads[threadIndex]; const marker = fullMarkerListPerThread[threadIndex][otherMarkerIndex]; console.log( - ` - marker ${otherMarkerIndex} (thread index: ${threadIndex}) at time ${flowMarker.time} on thread ${thread.name} (pid: ${thread.pid}, tid: ${thread.tid}):`, + ` - marker ${otherMarkerIndex} (thread index: ${threadIndex}) at time ${flowMarker.startTime} on thread ${thread.name} (pid: ${thread.pid}, tid: ${thread.tid}):`, marker ); const directlyConnectedFlows = flowMarkerFlowsPerThread[threadIndex][ @@ -2331,3 +2273,242 @@ export function printFlow( // ); // } } + +export function computeFlowTiming( + profileFlowInfo: ProfileFlowInfo, + activeFlows: IndexIntoFlowTable[] +): FlowTiming { + let incomingContextFlows = []; + let directlyConnectedFlows = []; + let outgoingContextFlows = []; + + for (const flow of activeFlows) { + const connectedFlows = getConnectedFlowInfo(flow, profileFlowInfo); + incomingContextFlows.push(...connectedFlows.incomingContextFlows); + directlyConnectedFlows.push(...connectedFlows.directlyConnectedFlows); + outgoingContextFlows.push(...connectedFlows.outgoingContextFlows); + } + sortAndDedup(incomingContextFlows); + sortAndDedup(directlyConnectedFlows); + sortAndDedup(outgoingContextFlows); + + directlyConnectedFlows = directlyConnectedFlows.filter( + (icf) => activeFlows.indexOf(icf) === -1 + ); + incomingContextFlows = incomingContextFlows.filter( + (icf) => + activeFlows.indexOf(icf) === -1 && + directlyConnectedFlows.indexOf(icf) === -1 + ); + outgoingContextFlows = outgoingContextFlows.filter( + (icf) => + activeFlows.indexOf(icf) === -1 && + directlyConnectedFlows.indexOf(icf) === -1 && + incomingContextFlows.indexOf(icf) === -1 + ); + + const rawRows: Array<[FlowTimingRowType, IndexIntoFlowTable]> = [ + ...incomingContextFlows.map<[FlowTimingRowType, IndexIntoFlowTable]>( + (flowIndex) => ['INCOMING_CONTEXT', flowIndex] + ), + ...activeFlows.map<[FlowTimingRowType, IndexIntoFlowTable]>((flowIndex) => [ + 'ACTIVE', + flowIndex, + ]), + ...directlyConnectedFlows.map<[FlowTimingRowType, IndexIntoFlowTable]>( + (flowIndex) => ['DIRECTLY_CONNECTED', flowIndex] + ), + ...outgoingContextFlows.map<[FlowTimingRowType, IndexIntoFlowTable]>( + (flowIndex) => ['OUTGOING_CONTEXT', flowIndex] + ), + ]; + + const rows: FlowTimingRow[] = []; + + const { flowTable, flowMarkersPerThread } = profileFlowInfo; + + const flowIndexToRowIndex = new Map(); + + for (let rowIndex = 0; rowIndex < rawRows.length; rowIndex++) { + const [rowType, flowIndex] = rawRows[rowIndex]; + flowIndexToRowIndex.set(flowIndex, rowIndex); + + const flow = flowTable[flowIndex]; + const { flowMarkers, startTime, endTime } = flow; + const flowMarkerCount = flowMarkers.length; + + const markers: FlowTimingRowMarkerTable = { + length: flowMarkerCount, + threadIndex: new Int32Array(flowMarkerCount), + markerIndex: new Int32Array(flowMarkerCount), + flowMarkerIndex: new Int32Array(flowMarkerCount), + startTime: new Float64Array(flowMarkerCount), + endTime: new Float64Array(flowMarkerCount), + isInstant: new Uint8Array(flowMarkerCount), + }; + + for (let i = 0; i < flowMarkerCount; i++) { + const { threadIndex, flowMarkerIndex } = flowMarkers[i]; + const flowMarker = flowMarkersPerThread[threadIndex][flowMarkerIndex]; + const { markerIndex, startTime, endTime } = flowMarker; + markers.threadIndex[i] = threadIndex; + markers.markerIndex[i] = markerIndex; + markers.flowMarkerIndex[i] = flowMarkerIndex; + markers.startTime[i] = startTime; + if (endTime === null) { + markers.endTime[i] = startTime; + markers.isInstant[i] = 1; + } else { + markers.endTime[i] = endTime; + } + } + + rows.push({ + label: `Flow ${flowIndex}`, + rowType, + flowIndex, + flowStart: startTime, + flowEnd: ensureExists(endTime), + markers, + }); + } + + return { rows, flowIndexToRowIndex, profileFlowInfo }; +} + +function _arrowForMarker( + flowMarkerIndex: number, + flowIndexToRowIndex: Map, + flowMarkers: FlowMarker[], + flowMarkerFlows: IndexIntoFlowTable[][] +): FlowTimingArrow | null { + const { startTime, parentContextFlowMarker } = flowMarkers[flowMarkerIndex]; + + const rowIndexesFrom = []; + const rowIndexesTo = []; + let minRowIndex = -1; + let maxRowIndex = -1; + if (parentContextFlowMarker !== null) { + for (const flowIndex of flowMarkerFlows[parentContextFlowMarker]) { + const rowIndex = flowIndexToRowIndex.get(flowIndex); + if (rowIndex !== undefined) { + rowIndexesFrom.push(rowIndex); + if (minRowIndex === -1) { + minRowIndex = rowIndex; + maxRowIndex = rowIndex; + } else { + if (rowIndex < minRowIndex) { + minRowIndex = rowIndex; + } + if (rowIndex > maxRowIndex) { + maxRowIndex = rowIndex; + } + } + } + } + } + for (const flowIndex of flowMarkerFlows[flowMarkerIndex]) { + const rowIndex = flowIndexToRowIndex.get(flowIndex); + if (rowIndex !== undefined) { + rowIndexesTo.push(rowIndex); + if (minRowIndex === -1) { + minRowIndex = rowIndex; + maxRowIndex = rowIndex; + } else { + if (rowIndex < minRowIndex) { + minRowIndex = rowIndex; + } + if (rowIndex > maxRowIndex) { + maxRowIndex = rowIndex; + } + } + } + } + if (minRowIndex === maxRowIndex) { + return null; + } + return { + time: startTime, + rowIndexesFrom, + rowIndexesTo, + minRowIndex, + maxRowIndex, + }; +} + +export function computeArrowsRelatedToMarker( + threadIndex: ThreadIndex, + flowMarkerIndex: number, + flowTiming: FlowTiming + // flowIndexToRowIndex: Map, + // profileFlowInfo: ProfileFlowInfo +): FlowTimingArrow[] { + // We have marker-shared-between-flows "arrows" (or actually connections) + // And we have marker-has-this-parent arrows. + + // For shared-between-flows marker, as we iterate over a flow's flow markers, we find (threadIndex, flowMarkerIndex) pairs. + // From the flow marekr, we can get its "flow marker flows", which has the flow indexes for all the flows it is part of. + // In this function, we make it so that we add the arrows for the shared flows only when we encounter the marker in the first flow of the set. + // We also only do it for the activeFlows and not for the context flows. For the context flows we might not have all the directly-connected flows present in the rows, specifically we may not have the flow with the minimum index. + + const { flowIndexToRowIndex, profileFlowInfo } = flowTiming; + const { flowMarkersPerThread, flowMarkerFlowsPerThread } = profileFlowInfo; + const flowMarkers = flowMarkersPerThread[threadIndex]; + const flowMarkerFlows = flowMarkerFlowsPerThread[threadIndex]; + + const arrows: FlowTimingArrow[] = []; + + const flowMarker = flowMarkersPerThread[threadIndex][flowMarkerIndex]; + let incomingFlowMarkerIndex = flowMarkerIndex; + let incomingFlowMarker = flowMarker; + while (true) { + const { parentContextFlowMarker } = incomingFlowMarker; + const arrow = _arrowForMarker( + incomingFlowMarkerIndex, + flowIndexToRowIndex, + flowMarkers, + flowMarkerFlows + ); + if (arrow === null) { + break; + } + arrows.push(arrow); + if (parentContextFlowMarker === null) { + break; + } + incomingFlowMarkerIndex = parentContextFlowMarker; + incomingFlowMarker = flowMarkers[incomingFlowMarkerIndex]; + } + + const stack = [ + { + childMarkers: flowMarker.childContextFlowMarkers, + currentIndex: 0, + }, + ]; + while (stack.length > 0) { + const stackTop = stack[stack.length - 1]; + const { currentIndex, childMarkers } = stackTop; + if (currentIndex >= childMarkers.length) { + stack.pop(); + continue; + } + const currentFlowMarkerIndex = childMarkers[currentIndex]; + stackTop.currentIndex = currentIndex + 1; + const arrow = _arrowForMarker( + currentFlowMarkerIndex, + flowIndexToRowIndex, + flowMarkers, + flowMarkerFlows + ); + if (arrow !== null) { + arrows.push(arrow); + const children = + flowMarkers[currentFlowMarkerIndex].childContextFlowMarkers; + if (children.length !== 0) { + stack.push({ childMarkers: children, currentIndex: 0 }); + } + } + } + return arrows; +} diff --git a/src/reducers/url-state.ts b/src/reducers/url-state.ts index 5374e6440f..b5e94325a1 100644 --- a/src/reducers/url-state.ts +++ b/src/reducers/url-state.ts @@ -24,6 +24,7 @@ import type { IsOpenPerPanelState, TabID, SelectedMarkersPerThread, + IndexIntoFlowTable, } from 'firefox-profiler/types'; import type { TabSlug } from '../app-logic/tabs-handling'; @@ -761,6 +762,17 @@ const isBottomBoxFullscreen: Reducer = (state = false, action) => { } }; +const activeFlows: Reducer = (state = [], action) => { + switch (action.type) { + case 'CHANGE_ACTIVE_FLOWS': { + const { activeFlows } = action; + return activeFlows; + } + default: + return state; + } +}; + /** * This value is only set from the URL and never changed. */ @@ -805,6 +817,7 @@ const profileSpecific = combineReducers({ transforms, sourceView, assemblyView, + activeFlows, isBottomBoxOpenPerPanel, isBottomBoxFullscreen, timelineType, diff --git a/src/selectors/flow.ts b/src/selectors/flow.ts index ebdebd1bc9..8ce86ed213 100644 --- a/src/selectors/flow.ts +++ b/src/selectors/flow.ts @@ -5,13 +5,27 @@ // @flow import { createSelector } from 'reselect'; -import { computeProfileFlowInfo } from '../profile-logic/marker-data'; -import type { ProfileFlowInfo } from '../profile-logic/marker-data'; +import { + computeProfileFlowInfo, + computeFlowTiming, +} from '../profile-logic/marker-data'; import { getThreadSelectors } from './per-thread'; +import { getActiveFlows } from './url-state'; import type { ThreadSelectors } from './per-thread'; -import { getThreads, getMarkerSchema, getRawProfileSharedData } from './profile'; +import { + getThreads, + getMarkerSchema, + getRawProfileSharedData, +} from './profile'; -import type { Selector, State, Marker } from 'firefox-profiler/types'; +import type { + Selector, + State, + MarkerIndex, + Marker, + ProfileFlowInfo, + FlowTiming, +} from 'firefox-profiler/types'; function _arraysShallowEqual(arr1: any[], arr2: any[]): boolean { return arr1.length === arr2.length && arr1.every((val, i) => val === arr2[i]); @@ -40,6 +54,12 @@ export const getFullMarkerListPerThread: Selector = getFullMarkerList(state) ); +export const getMarkerChartLabelGetterPerThread: Selector< + Array<(marker: MarkerIndex) => string> +> = _createSelectorForAllThreads(({ getMarkerChartLabelGetter }, state) => + getMarkerChartLabelGetter(state) +); + export const getProfileFlowInfo: Selector = createSelector( getFullMarkerListPerThread, getThreads, @@ -47,3 +67,9 @@ export const getProfileFlowInfo: Selector = createSelector( getRawProfileSharedData, computeProfileFlowInfo ); + +export const getFlowTiming: Selector = createSelector( + getProfileFlowInfo, + getActiveFlows, + computeFlowTiming +); diff --git a/src/selectors/url-state.ts b/src/selectors/url-state.ts index 599b59a748..ba21cca883 100644 --- a/src/selectors/url-state.ts +++ b/src/selectors/url-state.ts @@ -32,6 +32,7 @@ import type { TabID, IndexIntoSourceTable, MarkerIndex, + IndexIntoFlowTable, } from 'firefox-profiler/types'; import type { TabSlug } from '../app-logic/tabs-handling'; @@ -125,6 +126,8 @@ export const getNetworkSearchString: Selector = (state) => getProfileSpecificState(state).networkSearchString; export const getSelectedTab: Selector = (state) => getUrlState(state).selectedTab; +export const getActiveFlows: Selector = (state) => + getProfileSpecificState(state).activeFlows; export const getInvertCallstack: Selector = (state) => { const tab = getSelectedTab(state); const profileSpecific = getProfileSpecificState(state); diff --git a/src/types/actions.ts b/src/types/actions.ts index 01d57310b3..c4742013d4 100644 --- a/src/types/actions.ts +++ b/src/types/actions.ts @@ -28,6 +28,7 @@ import type { ThreadsKey, NativeSymbolInfo, ProfileIndexTranslationMaps, + IndexIntoFlowTable, } from './profile-derived'; import type { FuncToFuncsMap } from '../profile-logic/symbolication'; import type { TemporaryError } from '../utils/errors'; @@ -236,6 +237,10 @@ type ProfileAction = readonly threadsKey: ThreadsKey; readonly markerIndex: MarkerIndex | null; } + | { + readonly type: 'CHANGE_ACTIVE_FLOWS'; + readonly activeFlows: IndexIntoFlowTable[]; + } | { readonly type: 'UPDATE_PREVIEW_SELECTION'; readonly previewSelection: PreviewSelection | null; diff --git a/src/types/profile-derived.ts b/src/types/profile-derived.ts index 10fbae4239..94e3138322 100644 --- a/src/types/profile-derived.ts +++ b/src/types/profile-derived.ts @@ -642,6 +642,120 @@ export type FuncTableWithReservedFunctions = { >; }; +export type GlobalFlowMarkerHandle = { + threadIndex: number; + flowMarkerIndex: number; +}; + +// An index into the global flow table. +export type IndexIntoFlowTable = number; + +export type Flow = { + id: string; + startTime: Milliseconds; + endTime: Milliseconds | null; + // All markers which mention this flow, ordered by start time. + flowMarkers: GlobalFlowMarkerHandle[]; +}; + +export type ConnectedFlowInfo = { + // Flows whose marker set has a non-empty intersection with our marker set. + directlyConnectedFlows: IndexIntoFlowTable[]; + // Flows which have at least one marker in their marker set was a stack-based + // marker which was already running higher up on the stack when at least one + // of our stack-based or instant markers was running on the same thread. + // All flows in our incomingContextFlows set have this flow in their + // outgoingContextFlows set. + incomingContextFlows: IndexIntoFlowTable[]; + // Flows which have at least one stack-based or instant marker in their marker + // set which was running when one of the stack-based markers in our set was + // running higher up on the same thread's stack. + // All flows in our outgoingContextFlows set have this flow in their + // incomingContextFlows set. + outgoingContextFlows: IndexIntoFlowTable[]; +}; + +type FlowIDAndTerminating = { + flowID: string; + isTerminating: boolean; +}; + +export type FlowMarker = { + markerIndex: number; + startTime: Milliseconds; + endTime: Milliseconds | null; + // The index of the closest stack-based interval flow marker that encompasses + // this marker. ("Closest" means "with the most recent start time".) + // If non-null, parentContextFlowMarker is lower the index of this flow marker, + // i.e. this can only point "backwards" within the thread's flow markers array. + parentContextFlowMarker: number | null; + // The indexes of flow markers which have this flow marker as their parentContextFlowMarker. + // All indexes in this array after the index of this flow marker. + childContextFlowMarkers: number[]; + flowIDs: FlowIDAndTerminating[]; +}; + +export type FlowFieldDescriptor = { + key: string; + isTerminating: boolean; +}; + +export type FlowSchema = { + flowFields: FlowFieldDescriptor[]; + isStackBased: boolean; +}; + +export type FlowSchemasByName = Map; + +export type ProfileFlowInfo = { + flowTable: Flow[]; + flowsByID: Map; + flowMarkersPerThread: FlowMarker[][]; + // For each (threadIndex, flowMarkerIndex), the indexes of the flows (in + // ascending order) this marker is part of. + flowMarkerFlowsPerThread: IndexIntoFlowTable[][][]; + flowSchemasByName: FlowSchemasByName; +}; + +export type FlowTiming = { + rows: FlowTimingRow[]; + flowIndexToRowIndex: Map; + profileFlowInfo: ProfileFlowInfo; +}; + +export type FlowTimingRowType = + | 'INCOMING_CONTEXT' + | 'ACTIVE' + | 'DIRECTLY_CONNECTED' + | 'OUTGOING_CONTEXT'; + +export type FlowTimingRow = { + rowType: FlowTimingRowType; + label: string; + flowIndex: IndexIntoFlowTable; + flowStart: Milliseconds; + flowEnd: Milliseconds; + markers: FlowTimingRowMarkerTable; +}; + +export type FlowTimingRowMarkerTable = { + length: number; + threadIndex: Int32Array; // ThreadIndex[], + markerIndex: Int32Array; // MarkerIndex[], + flowMarkerIndex: Int32Array; // FlowMarkerIndex[], + startTime: Float64Array; // Milliseconds[], + endTime: Float64Array; // Milliseconds[], + isInstant: Uint8Array; // boolean[], +}; + +export type FlowTimingArrow = { + time: Milliseconds; + rowIndexesFrom: number[]; + rowIndexesTo: number[]; + minRowIndex: number; + maxRowIndex: number; +}; + /** * The marker timing contains the necessary information to draw markers very quickly * in the marker chart. It represents a single row of markers in the chart. diff --git a/src/types/state.ts b/src/types/state.ts index 5583e72181..ff11e1153a 100644 --- a/src/types/state.ts +++ b/src/types/state.ts @@ -31,6 +31,7 @@ import type { LocalTrack, TrackIndex, MarkerIndex, + IndexIntoFlowTable, ThreadsKey, NativeSymbolInfo, } from './profile-derived'; @@ -387,6 +388,7 @@ export type ProfileSpecificUrlState = { legacyHiddenThreads: ThreadIndex[] | null; selectedMarkers: SelectedMarkersPerThread; markerTableSort: SingleColumnSortState[] | null; + activeFlows: IndexIntoFlowTable[]; }; export type UrlState = { From 4e67f8dc669bccf1e0c4e14582c333fa07c7770e Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Tue, 11 Aug 2026 18:00:06 -0400 Subject: [PATCH 5/5] Experiment: Don't make marker tooltips sticky when a marker is clicked. Clicking a marker now also drives the flow panel, and the persisting tooltips get in the way, especially when there's one from the regular marker chart and one from the flow panel's marker chart. Also gate the prop-driven selectedItem tooltip sync in the shared chart canvas on stickyTooltips, so that flipping the flag off disables the sticky behavior entirely, not just for clicks on the canvas. --- src/components/flow-panel/Canvas.tsx | 3 +- src/components/marker-chart/Canvas.tsx | 6 +- src/components/shared/chart/Canvas.tsx | 3 +- src/test/components/MarkerChart.test.tsx | 30 +++----- .../__snapshots__/MarkerChart.test.tsx.snap | 77 ------------------- 5 files changed, 19 insertions(+), 100 deletions(-) diff --git a/src/components/flow-panel/Canvas.tsx b/src/components/flow-panel/Canvas.tsx index cf5b3878b8..3bf16b7aed 100644 --- a/src/components/flow-panel/Canvas.tsx +++ b/src/components/flow-panel/Canvas.tsx @@ -1092,7 +1092,8 @@ class FlowPanelCanvasImpl extends React.PureComponent { hitTest={this.hitTest} onMouseMove={this.onMouseMove} onMouseLeave={this.onMouseLeave} - stickyTooltips={true} + // Experiment: see the same comment in the marker chart canvas. + stickyTooltips={false} /> ); } diff --git a/src/components/marker-chart/Canvas.tsx b/src/components/marker-chart/Canvas.tsx index 54ad1deda0..baff6e6ad2 100644 --- a/src/components/marker-chart/Canvas.tsx +++ b/src/components/marker-chart/Canvas.tsx @@ -1093,7 +1093,11 @@ class MarkerChartCanvasImpl extends React.PureComponent { hitTest={this.hitTest} onMouseMove={this.onMouseMove} onMouseLeave={this.onMouseLeave} - stickyTooltips={true} + // Experiment: sticky tooltips are disabled because clicking a marker + // now also updates the flow panel, and the persisting tooltips (one + // from the marker chart plus one from the flow panel's marker chart) + // get in the way. + stickyTooltips={false} selectedItem={selectedMarkerIndex} selectedItemTooltipOffset={this._getSelectedItemTooltipOffset()} /> diff --git a/src/components/shared/chart/Canvas.tsx b/src/components/shared/chart/Canvas.tsx index 4f12710e52..41a4f7e4c0 100644 --- a/src/components/shared/chart/Canvas.tsx +++ b/src/components/shared/chart/Canvas.tsx @@ -410,6 +410,7 @@ export class ChartCanvas extends React.Component< // If the viewport hasn't been laid out yet, // componentDidUpdate will pick it up once it becomes non-zero. if ( + this.props.stickyTooltips && this.props.selectedItem !== undefined && this.props.containerWidth !== 0 ) { @@ -427,7 +428,7 @@ export class ChartCanvas extends React.Component< override componentDidUpdate(prevProps: Props, prevState: State) { if (prevProps !== this.props) { - if (this.props.selectedItem !== undefined) { + if (this.props.stickyTooltips && this.props.selectedItem !== undefined) { const selectedItemChanged = this.props.selectedItem !== prevProps.selectedItem; const canvasJustGotSize = diff --git a/src/test/components/MarkerChart.test.tsx b/src/test/components/MarkerChart.test.tsx index d789a8f5e6..02de462973 100644 --- a/src/test/components/MarkerChart.test.tsx +++ b/src/test/components/MarkerChart.test.tsx @@ -306,7 +306,7 @@ describe('MarkerChart', function () { ).toMatchSnapshot(); }); - it('persists the selected marker tooltips properly', () => { + it('does not persist the tooltip of a clicked marker', () => { window.devicePixelRatio = 1; const profile = getProfileWithMarkers(MARKERS); @@ -341,13 +341,13 @@ describe('MarkerChart', function () { const position = findFillTextPositionFromDrawLog(drawLog, 'Marker B'); leftClick(position); - // The tooltip should be displayed - expect( - ensureExists( - document.querySelector('.tooltip'), - 'A tooltip component must exist for this test.' - ) - ).toMatchSnapshot(); + // The tooltip is displayed because the mouse is still hovering the marker, + // but it isn't sticky: it isn't marked as clickable. + const tooltip = ensureExists( + document.querySelector('.tooltip'), + 'A tooltip component must exist for this test.' + ); + expect(tooltip).not.toHaveClass('clickable'); // Move the mouse outside of the marker. fireMouseEvent('mousemove', { @@ -357,27 +357,17 @@ describe('MarkerChart', function () { pageY: 0, }); - // Make sure that we have the tooltip persisted. - expect(document.querySelector('.tooltip')).toBeTruthy(); - - // Click outside of the marker. - leftClick({ x: 0, y: 0 }); - - // Now the tooltip should not be displayed. + // The tooltip of the clicked marker isn't persisted. expect(document.querySelector('.tooltip')).toBeFalsy(); - // The tooltip should be displayed also on double click. + // Same for double clicks. leftClick(position, true); - - // Move the mouse outside of the marker. fireMouseEvent('mousemove', { offsetX: 0, offsetY: 0, pageX: 0, pageY: 0, }); - - // The double click shouldn't make the tooltip persisted. expect(document.querySelector('.tooltip')).toBeFalsy(); }); diff --git a/src/test/components/__snapshots__/MarkerChart.test.tsx.snap b/src/test/components/__snapshots__/MarkerChart.test.tsx.snap index e694aff34e..8f7727a49d 100644 --- a/src/test/components/__snapshots__/MarkerChart.test.tsx.snap +++ b/src/test/components/__snapshots__/MarkerChart.test.tsx.snap @@ -158,83 +158,6 @@ exports[`MarkerChart context menus displays when right clicking on a marker 1`] `; -exports[`MarkerChart persists the selected marker tooltips properly 1`] = ` -
-
-
-
-
- 6ms -
-
- - Marker B - -
-
-
-
-
- Description - : -
-
- UserTiming is created using the DOM APIs performance.mark() and performance.measure(). -
-
- Name - : -
- Marker B -
- Entry Type - : -
- measure -
- Track - : -
- Empty -
-
-
-`; - exports[`MarkerChart renders the hoveredItem markers properly 1`] = ` Array [ Array [