diff --git a/packages/redux-devtools-rtk-query-monitor/src/selectors.ts b/packages/redux-devtools-rtk-query-monitor/src/selectors.ts index a38b65fb..459fd862 100644 --- a/packages/redux-devtools-rtk-query-monitor/src/selectors.ts +++ b/packages/redux-devtools-rtk-query-monitor/src/selectors.ts @@ -45,6 +45,7 @@ export function computeSelectorSource>( return { userState, monitorState, + currentStateIndex, actionsById, }; } @@ -236,6 +237,8 @@ export function createInspectorSelectors(): InspectorSelectors { const selectApiStatsOfCurrentQuery = createSelector( selectApiOfCurrentQuery, + (selectorsSource) => selectorsSource.actionsById, + (selectorsSource) => selectorsSource.currentStateIndex, generateApiStatsOfCurrentQuery ); diff --git a/packages/redux-devtools-rtk-query-monitor/src/types.ts b/packages/redux-devtools-rtk-query-monitor/src/types.ts index 647585ba..a6198b7d 100644 --- a/packages/redux-devtools-rtk-query-monitor/src/types.ts +++ b/packages/redux-devtools-rtk-query-monitor/src/types.ts @@ -93,6 +93,7 @@ export interface SelectOption< export interface SelectorsSource { userState: S | null; monitorState: RtkQueryMonitorState; + currentStateIndex: number; actionsById: LiftedState['actionsById']; } @@ -120,11 +121,20 @@ export type QueryTally = { } & Tally; +export interface RtkRequestTiming { + requestId: string; + queryKey: string; + endpointName: string; + startedAt: string; + completedAt: string; + duration: string; +} + export interface QueryTimings { - readonly oldest: { key: string; at: string } | null; - readonly latest: { key: string; at: string } | null; - readonly slowest: { key: string; duration: string } | null; - readonly fastest: { key: string; duration: string } | null; + readonly oldest: RtkRequestTiming | null; + readonly latest: RtkRequestTiming | null; + readonly slowest: RtkRequestTiming | null; + readonly fastest: RtkRequestTiming | null; readonly average: string; readonly median: string; } @@ -138,9 +148,9 @@ export interface ApiStats { readonly timings: ApiTimings; readonly tally: Readonly<{ subscriptions: number; - queries: QueryTally; + cachedQueries: QueryTally; tagTypes: number; - mutations: QueryTally; + cachedMutations: QueryTally; }>; } @@ -158,3 +168,12 @@ export interface RTKStatusFlags { readonly isSuccess: boolean; readonly isError: boolean; } + +export type RtkRequest = { + status: QueryStatus; + queryKey: string; + requestId: string; + endpointName: string; + startedTimeStamp?: number; + fulfilledTimeStamp?: number; +}; diff --git a/packages/redux-devtools-rtk-query-monitor/src/utils/comparators.ts b/packages/redux-devtools-rtk-query-monitor/src/utils/comparators.ts index cd12d1ec..ff923a21 100644 --- a/packages/redux-devtools-rtk-query-monitor/src/utils/comparators.ts +++ b/packages/redux-devtools-rtk-query-monitor/src/utils/comparators.ts @@ -48,10 +48,9 @@ function sortQueryByStatus( return thisTerm - thatTerm; } -function compareJSONPrimitive( - a: T, - b: T -): number { +export function compareJSONPrimitive< + T extends string | number | boolean | null +>(a: T, b: T): number { if (a === b) { return 0; } diff --git a/packages/redux-devtools-rtk-query-monitor/src/utils/rtk-query.ts b/packages/redux-devtools-rtk-query-monitor/src/utils/rtk-query.ts index 91399400..d8006408 100644 --- a/packages/redux-devtools-rtk-query-monitor/src/utils/rtk-query.ts +++ b/packages/redux-devtools-rtk-query-monitor/src/utils/rtk-query.ts @@ -1,4 +1,4 @@ -import { AnyAction, isAllOf, isAnyOf, isPlainObject } from '@reduxjs/toolkit'; +import { Action, AnyAction, isAllOf, isPlainObject } from '@reduxjs/toolkit'; import { QueryStatus } from '@reduxjs/toolkit/query'; import { QueryInfo, @@ -17,9 +17,11 @@ import { SelectorsSource, RtkMutationState, RtkResourceInfo, + RtkRequest, + RtkRequestTiming, } from '../types'; import { missingTagId } from '../monitor-config'; -import { Comparator } from './comparators'; +import { Comparator, compareJSONPrimitive } from './comparators'; import { emptyArray } from './object'; import { formatMs } from './formatters'; import * as statistics from './statistics'; @@ -199,44 +201,186 @@ function tallySubscriptions( return output; } +function computeRtkQueryRequests( + type: 'queries' | 'mutations', + api: RtkQueryApiState, + sortedActions: AnyAction[], + currentStateIndex: SelectorsSource['currentStateIndex'] +): Readonly> { + const requestById: Record = {}; + + const matcher = + type === 'queries' + ? matchesExecuteQuery(api.config.reducerPath) + : matchesExecuteMutation(api.config.reducerPath); + + for ( + let i = 0, len = sortedActions.length; + i < len && i <= currentStateIndex; + i++ + ) { + const action = sortedActions[i]; + + if (matcher(action)) { + let requestRecord: RtkRequest | undefined = + requestById[action.meta.requestId]; + + if (!requestRecord) { + const queryCacheKey: string | undefined = ( + action.meta as Record + )?.arg?.queryCacheKey; + + const queryKey = + typeof queryCacheKey === 'string' + ? queryCacheKey + : action.meta.requestId; + + const endpointName: string = + (action.meta as any)?.arg?.endpointName ?? '-'; + + requestById[action.meta.requestId] = requestRecord = { + queryKey, + requestId: action.meta.requestId, + endpointName, + status: action.meta.requestStatus, + }; + } + + requestRecord.status = action.meta.requestStatus; + + if ( + action.meta.requestStatus === QueryStatus.pending && + typeof (action.meta as any).startedTimeStamp === 'number' + ) { + requestRecord.startedTimeStamp = (action.meta as any).startedTimeStamp; + } + + if ( + action.meta.requestStatus === QueryStatus.fulfilled && + typeof (action.meta as any).fulfilledTimeStamp === 'number' + ) { + requestRecord.fulfilledTimeStamp = ( + action.meta as any + ).fulfilledTimeStamp; + } + } + } + + const requestIds = Object.keys(requestById); + + // Patch queries that have pending actions that are committed + for (let i = 0, len = requestIds.length; i < len; i++) { + const requestId = requestIds[i]; + const request = requestById[requestId]; + + if ( + typeof request.startedTimeStamp === 'undefined' && + typeof request.fulfilledTimeStamp === 'number' + ) { + const startedTimeStampFromCache = + api[type][request.queryKey]?.startedTimeStamp; + + if (typeof startedTimeStampFromCache === 'number') { + request.startedTimeStamp = startedTimeStampFromCache; + } + } + } + + // Add queries that have pending and fulfilled actions committed + const queryCacheEntries = Object.entries(api[type] ?? {}); + + for (let i = 0, len = queryCacheEntries.length; i < len; i++) { + const [queryCacheKey, queryCache] = queryCacheEntries[i]; + const requestId: string = + type === 'queries' + ? (queryCache as typeof api['queries'][string])?.requestId ?? '' + : queryCacheKey; + if ( + queryCache && + !Object.prototype.hasOwnProperty.call(requestById, requestId) + ) { + const startedTimeStamp = queryCache?.startedTimeStamp; + const fulfilledTimeStamp = queryCache?.fulfilledTimeStamp; + + if ( + typeof startedTimeStamp === 'number' && + typeof fulfilledTimeStamp === 'number' + ) { + requestById[requestId] = { + queryKey: queryCacheKey, + requestId, + endpointName: queryCache.endpointName ?? '', + startedTimeStamp, + fulfilledTimeStamp, + status: queryCache.status, + }; + } + } + } + + return requestById; +} + +function formatRtkRequest( + rtkRequest: RtkRequest | null +): RtkRequestTiming | null { + if (!rtkRequest) { + return null; + } + + const fulfilledTimeStamp = rtkRequest.fulfilledTimeStamp; + const startedTimeStamp = rtkRequest.startedTimeStamp; + + const output: RtkRequestTiming = { + queryKey: rtkRequest.queryKey, + requestId: rtkRequest.requestId, + endpointName: rtkRequest.endpointName, + startedAt: '-', + completedAt: '-', + duration: '-', + }; + + if ( + typeof fulfilledTimeStamp === 'number' && + typeof startedTimeStamp === 'number' + ) { + output.startedAt = new Date(startedTimeStamp).toISOString(); + output.completedAt = new Date(fulfilledTimeStamp).toISOString(); + output.duration = formatMs(fulfilledTimeStamp - startedTimeStamp); + } + + return output; +} + function computeQueryApiTimings( - queriesOrMutations: - | RtkQueryApiState['queries'] - | RtkQueryApiState['mutations'] + requestById: Readonly> ): QueryTimings { - type SpeedReport = { key: string | null; at: string | number }; - type DurationReport = { key: string | null; duration: string | number }; - let latest: null | SpeedReport = { key: null, at: -1 }; - let oldest: null | SpeedReport = { - key: null, - at: Number.MAX_SAFE_INTEGER, - }; - let slowest: null | DurationReport = { key: null, duration: -1 }; - let fastest: null | DurationReport = { - key: null, - duration: Number.MAX_SAFE_INTEGER, - }; + const requests = Object.values(requestById); + + let latestRequest: RtkRequest | null = null; + let oldestRequest: null | RtkRequest = null; + let slowestRequest: RtkRequest | null = null; + let fastestRequest: RtkRequest | null = null; + let slowestDuration = 0; + let fastestDuration = Number.MAX_SAFE_INTEGER; const pendingDurations: number[] = []; - const queryKeys = Object.keys(queriesOrMutations); - - for (let i = 0, len = queryKeys.length; i < len; i++) { - const queryKey = queryKeys[i]; - const query = queriesOrMutations[queryKey]; - - const fulfilledTimeStamp = query?.fulfilledTimeStamp; - const startedTimeStamp = query?.startedTimeStamp; + for (let i = 0, len = requests.length; i < len; i++) { + const request = requests[i]; + const { fulfilledTimeStamp, startedTimeStamp } = request; if (typeof fulfilledTimeStamp === 'number') { - if (fulfilledTimeStamp > latest.at) { - latest.key = queryKey; - latest.at = fulfilledTimeStamp; + const latestFulfilledTimeStamp = latestRequest?.fulfilledTimeStamp || 0; + const oldestFulfilledTimeStamp = + oldestRequest?.fulfilledTimeStamp || Number.MAX_SAFE_INTEGER; + + if (fulfilledTimeStamp > latestFulfilledTimeStamp) { + latestRequest = request; } - if (fulfilledTimeStamp < oldest.at) { - oldest.key = queryKey; - oldest.at = fulfilledTimeStamp; + if (fulfilledTimeStamp < oldestFulfilledTimeStamp) { + oldestRequest = request; } if ( @@ -244,46 +388,21 @@ function computeQueryApiTimings( startedTimeStamp <= fulfilledTimeStamp ) { const pendingDuration = fulfilledTimeStamp - startedTimeStamp; - pendingDurations.push(pendingDuration); - if (pendingDuration > slowest.duration) { - slowest.key = queryKey; - slowest.duration = pendingDuration; + if (pendingDuration > slowestDuration) { + slowestDuration = pendingDuration; + slowestRequest = request; } - if (pendingDuration < fastest.duration) { - fastest.key = queryKey; - fastest.duration = pendingDuration; + if (pendingDuration < fastestDuration) { + fastestDuration = pendingDuration; + fastestRequest = request; } } } } - if (latest.key !== null) { - latest.at = new Date(latest.at).toISOString(); - } else { - latest = null; - } - - if (oldest.key !== null) { - oldest.at = new Date(oldest.at).toISOString(); - } else { - oldest = null; - } - - if (slowest.key !== null) { - slowest.duration = formatMs(slowest.duration as number); - } else { - slowest = null; - } - - if (fastest.key !== null) { - fastest.duration = formatMs(fastest.duration as number); - } else { - fastest = null; - } - const average = pendingDurations.length > 0 ? formatMs(statistics.mean(pendingDurations)) @@ -295,34 +414,60 @@ function computeQueryApiTimings( : '-'; return { - latest, - oldest, - slowest, - fastest, + latest: formatRtkRequest(latestRequest), + oldest: formatRtkRequest(oldestRequest), + slowest: formatRtkRequest(slowestRequest), + fastest: formatRtkRequest(fastestRequest), average, median, - } as QueryTimings; + }; } -function computeApiTimings(api: RtkQueryApiState): ApiTimings { +function computeApiTimings( + api: RtkQueryApiState, + actionsById: SelectorsSource['actionsById'], + currentStateIndex: SelectorsSource['currentStateIndex'] +): ApiTimings { + const sortedActions = Object.entries(actionsById) + .sort((thisAction, thatAction) => + compareJSONPrimitive(Number(thisAction[0]), Number(thatAction[0])) + ) + .map((entry) => entry[1].action); + + const queryRequestsById = computeRtkQueryRequests( + 'queries', + api, + sortedActions, + currentStateIndex + ); + + const mutationRequestsById = computeRtkQueryRequests( + 'mutations', + api, + sortedActions, + currentStateIndex + ); + return { - queries: computeQueryApiTimings(api.queries), - mutations: computeQueryApiTimings(api.mutations), + queries: computeQueryApiTimings(queryRequestsById), + mutations: computeQueryApiTimings(mutationRequestsById), }; } export function generateApiStatsOfCurrentQuery( - api: RtkQueryApiState | null + api: RtkQueryApiState | null, + actionsById: SelectorsSource['actionsById'], + currentStateIndex: SelectorsSource['currentStateIndex'] ): ApiStats | null { if (!api) { return null; } return { - timings: computeApiTimings(api), + timings: computeApiTimings(api, actionsById, currentStateIndex), tally: { - queries: computeQueryTallyOf(api.queries), - mutations: computeQueryTallyOf(api.mutations), + cachedQueries: computeQueryTallyOf(api.queries), + cachedMutations: computeQueryTallyOf(api.mutations), tagTypes: Object.keys(api.provided).length, subscriptions: tallySubscriptions(api.subscriptions), }, @@ -441,29 +586,56 @@ export function getQueryStatusFlags({ * @see https://github.com/reduxjs/redux-toolkit/blob/b718e01d323d3ab4b913e5d88c9b90aa790bb975/src/query/core/buildThunks.ts#L415 */ export function matchesEndpoint(endpointName: unknown) { - return (action: any): action is AnyAction => + return (action: any): action is Action => endpointName != null && action?.meta?.arg?.endpointName === endpointName; } function matchesQueryKey(queryKey: string) { - return (action: any): action is AnyAction => + return (action: any): action is Action => action?.meta?.arg?.queryCacheKey === queryKey; } function macthesRequestId(requestId: string) { - return (action: any): action is AnyAction => + return (action: any): action is Action => action?.meta?.requestId === requestId; } function matchesReducerPath(reducerPath: string) { - return (action: any): action is AnyAction => + return (action: any): action is Action => typeof action?.type === 'string' && action.type.startsWith(reducerPath); } +function matchesExecuteQuery(reducerPath: string) { + return ( + action: any + ): action is Action & { + meta: { requestId: string; requestStatus: QueryStatus }; + } => { + return ( + typeof action?.type === 'string' && + action.type.startsWith(`${reducerPath}/executeQuery`) && + typeof action.meta?.requestId === 'string' && + typeof action.meta?.requestStatus === 'string' + ); + }; +} + +function matchesExecuteMutation(reducerPath: string) { + return ( + action: any + ): action is Action & { + meta: { requestId: string; requestStatus: QueryStatus }; + } => + typeof action?.type === 'string' && + action.type.startsWith(`${reducerPath}/executeMutation`) && + typeof action.meta?.requestId === 'string' && + typeof action.meta?.requestStatus === 'string'; +} + export function getActionsOfCurrentQuery( currentQuery: RtkResourceInfo | null, actionById: SelectorsSource['actionsById'] -): AnyAction[] { +): Action[] { if (!currentQuery) { return emptyArray; }