@redux-devtools/rtk-query-monitor
Version:
rtk-query monitor for Redux DevTools
412 lines (405 loc) • 14.7 kB
JavaScript
import { isAllOf, isPlainObject } from '@reduxjs/toolkit';
import { QueryStatus } from '@reduxjs/toolkit/query';
import { isRtkQuery262Provided } from '../types';
import { missingTagId } from '../monitor-config';
import { compareJSONPrimitive } from './comparators';
import { emptyArray } from './object';
import { formatMs } from './formatters';
import * as statistics from './statistics';
const rtkqueryApiStateKeys = ['queries', 'mutations', 'config', 'provided', 'subscriptions'];
/**
* Type guard used to select apis from the user store state.
* @param val
* @returns {boolean}
*/
export function isApiSlice(val) {
if (!isPlainObject(val)) {
return false;
}
for (let i = 0, len = rtkqueryApiStateKeys.length; i < len; i++) {
if (!isPlainObject(val[rtkqueryApiStateKeys[i]])) {
return false;
}
}
return true;
}
/**
* Indexes api states by their `reducerPath`.
*
* Returns `null` if there are no api slice or `reduxStoreState`
* is not an object.
*
* @param reduxStoreState
* @returns
*/
export function getApiStatesOf(reduxStoreState) {
if (!isPlainObject(reduxStoreState)) {
return null;
}
const output = {};
const keys = Object.keys(reduxStoreState);
for (let i = 0, len = keys.length; i < len; i++) {
const key = keys[i];
const value = reduxStoreState[key];
if (isApiSlice(value)) {
output[key] = value;
}
}
if (Object.keys(output).length === 0) {
return null;
}
return output;
}
export function extractAllApiQueries(apiStatesByReducerPath) {
if (!apiStatesByReducerPath) {
return emptyArray;
}
const reducerPaths = Object.keys(apiStatesByReducerPath);
const output = [];
for (let i = 0, len = reducerPaths.length; i < len; i++) {
const reducerPath = reducerPaths[i];
const api = apiStatesByReducerPath[reducerPath];
const queryKeys = Object.keys(api.queries);
for (let j = 0, qKeysLen = queryKeys.length; j < qKeysLen; j++) {
const queryKey = queryKeys[j];
const state = api.queries[queryKey];
if (state) {
output.push({
type: 'query',
reducerPath,
queryKey,
state
});
}
}
}
return output;
}
export function extractAllApiMutations(apiStatesByReducerPath) {
if (!apiStatesByReducerPath) {
return emptyArray;
}
const reducerPaths = Object.keys(apiStatesByReducerPath);
const output = [];
for (let i = 0, len = reducerPaths.length; i < len; i++) {
const reducerPath = reducerPaths[i];
const api = apiStatesByReducerPath[reducerPath];
const mutationKeys = Object.keys(api.mutations);
for (let j = 0, mKeysLen = mutationKeys.length; j < mKeysLen; j++) {
const queryKey = mutationKeys[j];
const state = api.mutations[queryKey];
if (state) {
output.push({
type: 'mutation',
reducerPath,
queryKey,
state
});
}
}
}
return output;
}
function computeQueryTallyOf(queryState) {
const queries = Object.values(queryState);
const output = {
count: 0
};
for (let i = 0, len = queries.length; i < len; i++) {
const query = queries[i];
if (query) {
output.count++;
if (!output[query.status]) {
output[query.status] = 1;
} else {
output[query.status]++;
}
}
}
return output;
}
function tallySubscriptions(subsState) {
const subsOfQueries = Object.values(subsState);
let output = 0;
for (let i = 0, len = subsOfQueries.length; i < len; i++) {
const subsOfQuery = subsOfQueries[i];
if (subsOfQuery) {
output += Object.keys(subsOfQuery).length;
}
}
return output;
}
function computeRtkQueryRequests(type, api, sortedActions, currentStateIndex) {
const requestById = {};
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 = requestById[action.meta.requestId];
if (!requestRecord) {
const queryCacheKey = action.meta?.arg?.queryCacheKey;
const queryKey = typeof queryCacheKey === 'string' ? queryCacheKey : action.meta.requestId;
const endpointName = action.meta?.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.startedTimeStamp === 'number') {
requestRecord.startedTimeStamp = action.meta.startedTimeStamp;
}
if (action.meta.requestStatus === QueryStatus.fulfilled && typeof action.meta.fulfilledTimeStamp === 'number') {
requestRecord.fulfilledTimeStamp = action.meta.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 = type === 'queries' ? queryCache?.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) {
if (!rtkRequest) {
return null;
}
const fulfilledTimeStamp = rtkRequest.fulfilledTimeStamp;
const startedTimeStamp = rtkRequest.startedTimeStamp;
const output = {
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(requestById) {
const requests = Object.values(requestById);
let latestRequest = null;
let oldestRequest = null;
let slowestRequest = null;
let fastestRequest = null;
let slowestDuration = 0;
let fastestDuration = Number.MAX_SAFE_INTEGER;
const pendingDurations = [];
for (let i = 0, len = requests.length; i < len; i++) {
const request = requests[i];
const {
fulfilledTimeStamp,
startedTimeStamp
} = request;
if (typeof fulfilledTimeStamp === 'number') {
const latestFulfilledTimeStamp = latestRequest?.fulfilledTimeStamp || 0;
const oldestFulfilledTimeStamp = oldestRequest?.fulfilledTimeStamp || Number.MAX_SAFE_INTEGER;
if (fulfilledTimeStamp > latestFulfilledTimeStamp) {
latestRequest = request;
}
if (fulfilledTimeStamp < oldestFulfilledTimeStamp) {
oldestRequest = request;
}
if (typeof startedTimeStamp === 'number' && startedTimeStamp <= fulfilledTimeStamp) {
const pendingDuration = fulfilledTimeStamp - startedTimeStamp;
pendingDurations.push(pendingDuration);
if (pendingDuration > slowestDuration) {
slowestDuration = pendingDuration;
slowestRequest = request;
}
if (pendingDuration < fastestDuration) {
fastestDuration = pendingDuration;
fastestRequest = request;
}
}
}
}
const average = pendingDurations.length > 0 ? formatMs(statistics.mean(pendingDurations)) : '-';
const median = pendingDurations.length > 0 ? formatMs(statistics.median(pendingDurations)) : '-';
return {
latest: formatRtkRequest(latestRequest),
oldest: formatRtkRequest(oldestRequest),
slowest: formatRtkRequest(slowestRequest),
fastest: formatRtkRequest(fastestRequest),
average,
median
};
}
function computeApiTimings(api, actionsById, currentStateIndex) {
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(queryRequestsById),
mutations: computeQueryApiTimings(mutationRequestsById)
};
}
export function generateApiStatsOfCurrentQuery(api, actionsById, currentStateIndex) {
if (!api) {
return null;
}
return {
timings: computeApiTimings(api, actionsById, currentStateIndex),
tally: {
cachedQueries: computeQueryTallyOf(api.queries),
cachedMutations: computeQueryTallyOf(api.mutations),
tagTypes: Object.keys(api.provided).length,
subscriptions: tallySubscriptions(api.subscriptions)
}
};
}
export function flipComparator(comparator) {
return function flipped(a, b) {
return comparator(b, a);
};
}
export function isQuerySelected(selectedQueryKey, queryInfo) {
return !!selectedQueryKey && selectedQueryKey.queryKey === queryInfo.queryKey && selectedQueryKey.reducerPath === queryInfo.reducerPath;
}
export function getApiStateOf(queryInfo, apiStates) {
if (!apiStates || !queryInfo) {
return null;
}
return apiStates[queryInfo.reducerPath] ?? null;
}
export function getQuerySubscriptionsOf(queryInfo, apiStates) {
if (!apiStates || !queryInfo) {
return null;
}
return apiStates[queryInfo.reducerPath]?.subscriptions?.[queryInfo.queryKey] ?? null;
}
export function getProvidedOf(queryInfo, apiStates) {
if (!apiStates || !queryInfo) {
return null;
}
return apiStates[queryInfo.reducerPath]?.provided ?? null;
}
export function getQueryTagsOf(resInfo, provided) {
if (!resInfo || resInfo.type === 'mutation' || !provided) {
return emptyArray;
}
// Handle `api.provided` schema change with RTK Query tag handling.
// Originally, `api.provided` was a `Record<string, Record<string, string[]>>`,
// directly containing the tag names.
// With https://github.com/reduxjs/redux-toolkit/pull/4910 , that changes to
// change the top level to be `{tags, keys}`, with `tags` containing the tag names.
// Handle the newer structure by extracting the right field if it exists.
const actualProvided = isRtkQuery262Provided(provided) ? provided.tags : provided;
const tagTypes = Object.keys(actualProvided);
if (tagTypes.length < 1) {
return emptyArray;
}
const output = [];
for (const [type, tagIds] of Object.entries(actualProvided)) {
if (tagIds) {
for (const [id, queryKeys] of Object.entries(tagIds)) {
if (queryKeys.includes(resInfo.queryKey)) {
const tag = {
type
};
if (id !== missingTagId) {
tag.id = id;
}
output.push(tag);
}
}
}
}
return output;
}
/**
* Computes query status flags.
* @param status
* @see https://redux-toolkit.js.org/rtk-query/usage/queries#frequently-used-query-hook-return-values
* @see https://github.com/reduxjs/redux-toolkit/blob/b718e01d323d3ab4b913e5d88c9b90aa790bb975/src/query/core/apiState.ts#L63
*/
export function getQueryStatusFlags(_ref) {
let {
status,
data
} = _ref;
return {
isUninitialized: status === QueryStatus.uninitialized,
isFetching: status === QueryStatus.pending,
isSuccess: status === QueryStatus.fulfilled && !!data,
isError: status === QueryStatus.rejected
};
}
/**
* endpoint matcher
* @param endpointName
* @see https://github.com/reduxjs/redux-toolkit/blob/b718e01d323d3ab4b913e5d88c9b90aa790bb975/src/query/core/buildThunks.ts#L415
*/
export function matchesEndpoint(endpointName) {
return action => endpointName != null && action?.meta?.arg?.endpointName === endpointName;
}
function matchesQueryKey(queryKey) {
return action => action?.meta?.arg?.queryCacheKey === queryKey;
}
function macthesRequestId(requestId) {
return action => action?.meta?.requestId === requestId;
}
function matchesReducerPath(reducerPath) {
return action => typeof action?.type === 'string' && action.type.startsWith(reducerPath);
}
function matchesExecuteQuery(reducerPath) {
return action => {
return typeof action?.type === 'string' && action.type.startsWith(`${reducerPath}/executeQuery`) && typeof action.meta?.requestId === 'string' && typeof action.meta?.requestStatus === 'string';
};
}
function matchesExecuteMutation(reducerPath) {
return action => typeof action?.type === 'string' && action.type.startsWith(`${reducerPath}/executeMutation`) && typeof action.meta?.requestId === 'string' && typeof action.meta?.requestStatus === 'string';
}
export function getActionsOfCurrentQuery(currentQuery, actionById) {
if (!currentQuery) {
return emptyArray;
}
let matcher;
if (currentQuery.type === 'mutation') {
matcher = isAllOf(matchesReducerPath(currentQuery.reducerPath), macthesRequestId(currentQuery.queryKey));
} else {
matcher = isAllOf(matchesReducerPath(currentQuery.reducerPath), matchesQueryKey(currentQuery.queryKey));
}
const output = [];
for (const [, liftedAction] of Object.entries(actionById)) {
if (matcher(liftedAction?.action)) {
output.push(liftedAction.action);
}
}
return output.length === 0 ? emptyArray : output;
}