diff --git a/packages/redux-devtools-inspector-monitor/demo/src/js/components/Pokemon.tsx b/packages/redux-devtools-inspector-monitor/demo/src/js/components/Pokemon.tsx new file mode 100644 index 00000000..a263d502 --- /dev/null +++ b/packages/redux-devtools-inspector-monitor/demo/src/js/components/Pokemon.tsx @@ -0,0 +1,69 @@ +import * as React from 'react'; +import { useGetPokemonByNameQuery } from '../rtk-query/pokemonApi'; +import type { PokemonName } from '../rtk-query/pokemon.data'; + +const intervalOptions = [ + { label: 'Off', value: 0 }, + { label: '3s', value: 3000 }, + { label: '5s', value: 5000 }, + { label: '10s', value: 10000 }, + { label: '1m', value: 60000 }, +]; + +export const Pokemon = ({ name }: { name: PokemonName }) => { + const [pollingInterval, setPollingInterval] = React.useState(60000); + + const { data, error, isLoading, isFetching, refetch } = + useGetPokemonByNameQuery(name, { + pollingInterval, + }); + + return ( +
+ {error ? ( + <>Oh no, there was an error loading {name} + ) : isLoading ? ( + <>Loading... + ) : data ? ( + <> +

{data.species.name}

+
+ {data.species.name} +
+
+ + +
+
+ +
+ + ) : ( + 'No Data' + )} +
+ ); +}; diff --git a/packages/redux-devtools-inspector-monitor/demo/src/js/components/PokemonView.tsx b/packages/redux-devtools-inspector-monitor/demo/src/js/components/PokemonView.tsx new file mode 100644 index 00000000..67314f61 --- /dev/null +++ b/packages/redux-devtools-inspector-monitor/demo/src/js/components/PokemonView.tsx @@ -0,0 +1,31 @@ +import * as React from 'react'; +import { PokemonName, POKEMON_NAMES } from '../rtk-query/pokemon.data'; +import { Pokemon } from './Pokemon'; + +const getRandomPokemonName = () => + POKEMON_NAMES[Math.floor(Math.random() * POKEMON_NAMES.length)]; + +export function PokemonView() { + const [pokemon, setPokemon] = React.useState(['bulbasaur']); + + return ( +
+
+ + +
+ + {pokemon.map((name, index) => ( + + ))} +
+ ); +} diff --git a/packages/redux-devtools-inspector-monitor/demo/src/js/DemoApp.tsx b/packages/redux-devtools-inspector-monitor/demo/src/js/containers/DemoApp.tsx similarity index 96% rename from packages/redux-devtools-inspector-monitor/demo/src/js/DemoApp.tsx rename to packages/redux-devtools-inspector-monitor/demo/src/js/containers/DemoApp.tsx index 83903219..e242e95b 100644 --- a/packages/redux-devtools-inspector-monitor/demo/src/js/DemoApp.tsx +++ b/packages/redux-devtools-inspector-monitor/demo/src/js/containers/DemoApp.tsx @@ -1,6 +1,6 @@ import React, { CSSProperties } from 'react'; import { connect } from 'react-redux'; -import pkg from '../../../package.json'; +import pkg from '../../../../package.json'; import Button from 'react-bootstrap/Button'; import FormGroup from 'react-bootstrap/FormGroup'; import FormControl from 'react-bootstrap/FormControl'; @@ -11,9 +11,10 @@ import InputGroup from 'react-bootstrap/InputGroup'; import Row from 'react-bootstrap/Row'; import * as base16 from 'base16'; import { push as pushRoute } from 'connected-react-router'; +import { PokemonView } from '../components/PokemonView'; import { Path } from 'history'; -import * as inspectorThemes from '../../../src/themes'; -import getOptions, { Options } from './getOptions'; +import * as inspectorThemes from '../../../../src/themes'; +import getOptions, { Options } from '../getOptions'; import { AddFunctionAction, AddHugeObjectAction, @@ -34,7 +35,7 @@ import { ShuffleArrayAction, TimeoutUpdateAction, ToggleTimeoutUpdateAction, -} from './reducers'; +} from '../reducers'; const styles: { wrapper: CSSProperties; @@ -56,9 +57,10 @@ const styles: { header: {}, content: { display: 'flex', + flexFlow: 'column', alignItems: 'center', - justifyContent: 'center', - height: '50%', + justifyContent: 'space-evenly', + padding: 8, }, buttons: { display: 'flex', @@ -251,6 +253,7 @@ class DemoApp extends React.Component { Shuffle Array +
diff --git a/packages/redux-devtools-inspector-monitor/demo/src/js/DevTools.tsx b/packages/redux-devtools-inspector-monitor/demo/src/js/containers/DevTools.tsx similarity index 86% rename from packages/redux-devtools-inspector-monitor/demo/src/js/DevTools.tsx rename to packages/redux-devtools-inspector-monitor/demo/src/js/containers/DevTools.tsx index 2d5b7c84..658e3054 100644 --- a/packages/redux-devtools-inspector-monitor/demo/src/js/DevTools.tsx +++ b/packages/redux-devtools-inspector-monitor/demo/src/js/containers/DevTools.tsx @@ -3,10 +3,10 @@ import { connect } from 'react-redux'; import { createDevTools } from '@redux-devtools/core'; import DockMonitor from '@redux-devtools/dock-monitor'; import { Location } from 'history'; -import DevtoolsInspector from '../../../src/DevtoolsInspector'; -import getOptions from './getOptions'; -import { base16Themes } from '../../../src/utils/createStylingFromTheme'; -import { DemoAppState } from './reducers'; +import DevtoolsInspector from '../../../../src/DevtoolsInspector'; +import getOptions from '../getOptions'; +import { base16Themes } from '../../../../src/utils/createStylingFromTheme'; +import { DemoAppState } from '../reducers'; const CustomComponent = () => (
+ getDefaultMiddleware({ + immutableCheck: false, + serializableCheck: false, + }).concat([pokemonApi.middleware]), + enhancers: [enhancer], +}); // createStore(createRootReducer(history), enhancer); render( diff --git a/packages/redux-devtools-inspector-monitor/demo/src/js/reducers.ts b/packages/redux-devtools-inspector-monitor/demo/src/js/reducers.ts index 3fdbe4fc..20d87ad5 100644 --- a/packages/redux-devtools-inspector-monitor/demo/src/js/reducers.ts +++ b/packages/redux-devtools-inspector-monitor/demo/src/js/reducers.ts @@ -7,6 +7,7 @@ import { RouterState, } from 'connected-react-router'; import { History } from 'history'; +import { pokemonApi } from './rtk-query/pokemonApi'; type Nested = { long: { nested: { path: { to: { a: string } } }[] } }; @@ -187,6 +188,7 @@ export interface DemoAppState { addFunction: { f: (a: number, b: number, c: number) => number } | null; addSymbol: { s: symbol; error: Error } | null; shuffleArray: unknown[]; + [pokemonApi.reducerPath]: ReturnType; } const createRootReducer = ( @@ -259,6 +261,7 @@ const createRootReducer = ( : state, shuffleArray: (state = DEFAULT_SHUFFLE_ARRAY, action) => action.type === 'SHUFFLE_ARRAY' ? shuffle(state) : state, + [pokemonApi.reducerPath]: pokemonApi.reducer, }); export default createRootReducer; diff --git a/packages/redux-devtools-inspector-monitor/demo/src/js/rtk-query/pokemon.data.ts b/packages/redux-devtools-inspector-monitor/demo/src/js/rtk-query/pokemon.data.ts new file mode 100644 index 00000000..83150cb7 --- /dev/null +++ b/packages/redux-devtools-inspector-monitor/demo/src/js/rtk-query/pokemon.data.ts @@ -0,0 +1,155 @@ +export const POKEMON_NAMES = [ + 'bulbasaur', + 'ivysaur', + 'venusaur', + 'charmander', + 'charmeleon', + 'charizard', + 'squirtle', + 'wartortle', + 'blastoise', + 'caterpie', + 'metapod', + 'butterfree', + 'weedle', + 'kakuna', + 'beedrill', + 'pidgey', + 'pidgeotto', + 'pidgeot', + 'rattata', + 'raticate', + 'spearow', + 'fearow', + 'ekans', + 'arbok', + 'pikachu', + 'raichu', + 'sandshrew', + 'sandslash', + 'nidoran', + 'nidorina', + 'nidoqueen', + 'nidoran', + 'nidorino', + 'nidoking', + 'clefairy', + 'clefable', + 'vulpix', + 'ninetales', + 'jigglypuff', + 'wigglytuff', + 'zubat', + 'golbat', + 'oddish', + 'gloom', + 'vileplume', + 'paras', + 'parasect', + 'venonat', + 'venomoth', + 'diglett', + 'dugtrio', + 'meowth', + 'persian', + 'psyduck', + 'golduck', + 'mankey', + 'primeape', + 'growlithe', + 'arcanine', + 'poliwag', + 'poliwhirl', + 'poliwrath', + 'abra', + 'kadabra', + 'alakazam', + 'machop', + 'machoke', + 'machamp', + 'bellsprout', + 'weepinbell', + 'victreebel', + 'tentacool', + 'tentacruel', + 'geodude', + 'graveler', + 'golem', + 'ponyta', + 'rapidash', + 'slowpoke', + 'slowbro', + 'magnemite', + 'magneton', + "farfetch'd", + 'doduo', + 'dodrio', + 'seel', + 'dewgong', + 'grimer', + 'muk', + 'shellder', + 'cloyster', + 'gastly', + 'haunter', + 'gengar', + 'onix', + 'drowzee', + 'hypno', + 'krabby', + 'kingler', + 'voltorb', + 'electrode', + 'exeggcute', + 'exeggutor', + 'cubone', + 'marowak', + 'hitmonlee', + 'hitmonchan', + 'lickitung', + 'koffing', + 'weezing', + 'rhyhorn', + 'rhydon', + 'chansey', + 'tangela', + 'kangaskhan', + 'horsea', + 'seadra', + 'goldeen', + 'seaking', + 'staryu', + 'starmie', + 'mr. mime', + 'scyther', + 'jynx', + 'electabuzz', + 'magmar', + 'pinsir', + 'tauros', + 'magikarp', + 'gyarados', + 'lapras', + 'ditto', + 'eevee', + 'vaporeon', + 'jolteon', + 'flareon', + 'porygon', + 'omanyte', + 'omastar', + 'kabuto', + 'kabutops', + 'aerodactyl', + 'snorlax', + 'articuno', + 'zapdos', + 'moltres', + 'dratini', + 'dragonair', + 'dragonite', + 'mewtwo', + 'mew', +] as const; + +export type PokemonName = typeof POKEMON_NAMES[number]; diff --git a/packages/redux-devtools-inspector-monitor/demo/src/js/rtk-query/pokemonApi.ts b/packages/redux-devtools-inspector-monitor/demo/src/js/rtk-query/pokemonApi.ts new file mode 100644 index 00000000..22dbe3d4 --- /dev/null +++ b/packages/redux-devtools-inspector-monitor/demo/src/js/rtk-query/pokemonApi.ts @@ -0,0 +1,20 @@ +import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; +import type { PokemonName } from './pokemon.data'; + +export const pokemonApi = createApi({ + reducerPath: 'pokemonApi', + baseQuery: fetchBaseQuery({ baseUrl: 'https://pokeapi.co/api/v2/' }), + tagTypes: ['pokemon'], + endpoints: (builder) => ({ + getPokemonByName: builder.query({ + query: (name: PokemonName) => `pokemon/${name}`, + providesTags: (result, error, name: PokemonName) => [ + { type: 'pokemon' }, + { type: 'pokemon', id: name }, + ], + }), + }), +}); + +// Export hooks for usage in functional components +export const { useGetPokemonByNameQuery } = pokemonApi; diff --git a/packages/redux-devtools-inspector-monitor/package.json b/packages/redux-devtools-inspector-monitor/package.json index 00a52500..0bcceefc 100644 --- a/packages/redux-devtools-inspector-monitor/package.json +++ b/packages/redux-devtools-inspector-monitor/package.json @@ -51,11 +51,13 @@ "react-base16-styling": "^0.8.0", "react-dragula": "^1.1.17", "react-json-tree": "^0.15.0", - "redux-devtools-themes": "^1.0.0" + "redux-devtools-themes": "^1.0.0", + "reselect": "^4.0.0" }, "devDependencies": { "@redux-devtools/core": "^3.9.0", "@redux-devtools/dock-monitor": "^1.4.0", + "@reduxjs/toolkit": "^1.6.0", "@types/dateformat": "^3.0.1", "@types/hex-rgba": "^1.0.0", "@types/history": "^4.7.8", diff --git a/packages/redux-devtools-inspector-monitor/src/ActionList.tsx b/packages/redux-devtools-inspector-monitor/src/ActionList.tsx index 4bfbd2af..1f545ad9 100644 --- a/packages/redux-devtools-inspector-monitor/src/ActionList.tsx +++ b/packages/redux-devtools-inspector-monitor/src/ActionList.tsx @@ -6,7 +6,6 @@ import { PerformAction } from '@redux-devtools/core'; import { StylingFunction } from 'react-base16-styling'; import ActionListRow from './ActionListRow'; import ActionListHeader from './ActionListHeader'; -import { filterActions } from './utils/filters'; import { ActionForm } from './redux'; function getTimestamps>( @@ -30,9 +29,9 @@ interface Props> { selectedActionId: number | null; startActionId: number | null; skippedActionIds: number[]; - computedStates: { state: S; error?: string }[]; draggableActions: boolean; actionForm: ActionForm; + filteredActionIds: number[]; hideMainButtons: boolean | undefined; hideActionButtons: boolean | undefined; styling: StylingFunction; @@ -129,12 +128,11 @@ export default class ActionList< hideActionButtons, onCommit, onSweep, - actionForm, onActionFormChange, onJumpToState, + actionForm, + filteredActionIds, } = this.props; - const filteredActionIds = filterActions(this.props); - return (
1} />
- {filteredActionIds.map((actionId) => ( + {filteredActionIds.map((actionId: number) => ( = ({ actionForm, onActionFormChange, }) => { - const { isNoopFilterActive } = actionForm; + const { isNoopFilterActive, isRtkQueryFilterActive } = actionForm; return ( <> @@ -42,7 +42,37 @@ const ActionListHeader: FunctionComponent = ({ onChange={(e) => onSearch(e.target.value)} placeholder="filter..." /> - {!hideMainButtons && ( +
+ + +
+
+ {!hideMainButtons && ( +
@@ -67,25 +97,8 @@ const ActionListHeader: FunctionComponent = ({
- )} -
-
- -
+
+ )} ); }; @@ -101,6 +114,7 @@ ActionListHeader.propTypes = { actionForm: PropTypes.shape({ searchValue: PropTypes.string.isRequired, isNoopFilterActive: PropTypes.bool.isRequired, + isRtkQueryFilterActive: PropTypes.bool.isRequired, }).isRequired, onActionFormChange: PropTypes.func.isRequired, }; diff --git a/packages/redux-devtools-inspector-monitor/src/DevtoolsInspector.tsx b/packages/redux-devtools-inspector-monitor/src/DevtoolsInspector.tsx index 4fbdae9d..a2f815f2 100644 --- a/packages/redux-devtools-inspector-monitor/src/DevtoolsInspector.tsx +++ b/packages/redux-devtools-inspector-monitor/src/DevtoolsInspector.tsx @@ -30,6 +30,8 @@ import { reducer, updateMonitorState, } from './redux'; +import { makeSelectFilteredActions } from './utils/filters'; +import { computeSelectorSource } from './utils/selectors'; // eslint-disable-next-line @typescript-eslint/unbound-method const { @@ -236,6 +238,8 @@ class DevtoolsInspector> extends PureComponent< updateSizeTimeout?: number; inspectorRef?: HTMLDivElement | null; + selectorsSource = computeSelectorSource(this.props); + componentDidMount() { this.updateSizeMode(); this.updateSizeTimeout = window.setInterval( @@ -293,6 +297,8 @@ class DevtoolsInspector> extends PureComponent< this.inspectorRef = node; }; + selectFilteredActions = makeSelectFilteredActions(); + render() { const { stagedActionIds: actionIds, @@ -316,6 +322,12 @@ class DevtoolsInspector> extends PureComponent< this.state; const { base16Theme, styling } = themeState; + this.selectorsSource = computeSelectorSource( + this.props, + this.selectorsSource + ); + const filteredActionIds = this.selectFilteredActions(this.selectorsSource); + return (
> extends PureComponent< styling, }} actionForm={actionForm} - computedStates={computedStates} + filteredActionIds={filteredActionIds} onSearch={this.handleSearch} onActionFormChange={this.handleActionFormChange} onSelect={this.handleSelectAction} diff --git a/packages/redux-devtools-inspector-monitor/src/redux.ts b/packages/redux-devtools-inspector-monitor/src/redux.ts index b82c742b..7e3616fb 100644 --- a/packages/redux-devtools-inspector-monitor/src/redux.ts +++ b/packages/redux-devtools-inspector-monitor/src/redux.ts @@ -10,6 +10,7 @@ const ACTION_FORM_VALUE_CHANGE = export interface ActionForm { searchValue: string; isNoopFilterActive: boolean; + isRtkQueryFilterActive: boolean; } export interface UpdateMonitorStateAction { type: typeof UPDATE_MONITOR_STATE; @@ -55,6 +56,7 @@ export const DEFAULT_STATE: DevtoolsInspectorState = { actionForm: { searchValue: '', isNoopFilterActive: false, + isRtkQueryFilterActive: false, }, }; diff --git a/packages/redux-devtools-inspector-monitor/src/utils/createStylingFromTheme.ts b/packages/redux-devtools-inspector-monitor/src/utils/createStylingFromTheme.ts index 0ef71768..f38f59c8 100644 --- a/packages/redux-devtools-inspector-monitor/src/utils/createStylingFromTheme.ts +++ b/packages/redux-devtools-inspector-monitor/src/utils/createStylingFromTheme.ts @@ -1,4 +1,4 @@ -import jss, { Styles, StyleSheet } from 'jss'; +import jss, { StyleSheet } from 'jss'; import preset from 'jss-preset-default'; import { createStyling } from 'react-base16-styling'; import rgba from 'hex-rgba'; @@ -33,6 +33,8 @@ const colorMap = (theme: Base16Theme) => ({ LINK_COLOR: rgba(theme.base0E, 90), LINK_HOVER_COLOR: theme.base0E, ERROR_COLOR: theme.base08, + TOGGLE_BUTTON_BACKGROUND: rgba(theme.base00, 70), + TOGGLE_BUTTON_SELECTED_BACKGROUND: theme.base04, }); type Color = keyof ReturnType; @@ -85,6 +87,7 @@ const getSheetFromColorMap = (map: ColorMap) => ({ actionListHeaderSecondRow: { padding: '5px 10px', + justifyContent: 'flex-end', }, actionListRows: { @@ -110,7 +113,6 @@ const getSheetFromColorMap = (map: ColorMap) => ({ actionListHeaderSelector: { display: 'inline-flex', - 'margin-right': '10px', }, actionListWide: { @@ -334,6 +336,55 @@ const getSheetFromColorMap = (map: ColorMap) => ({ 'background-color': map.TAB_BACK_SELECTED_COLOR, }, + toggleButtonWrapper: { + display: 'flex', + height: 20, + margin: 0, + padding: '0 10px 0 0', + '& > *': { + height: '100%', + }, + }, + + toggleButton: { + color: 'inherit', + cursor: 'pointer', + position: 'relative', + padding: '0px 4px', + fontSize: '0.7em', + letterSpacing: '-0.7px', + outline: 'none', + boxShadow: 'none', + fontWeight: '700', + 'border-style': 'solid', + 'border-width': '1px', + 'border-left-width': 0, + + '&:first-child': { + 'border-left-width': '1px', + 'border-top-left-radius': '3px', + 'border-bottom-left-radius': '3px', + }, + + '&:last-child': { + 'border-top-right-radius': '3px', + 'border-bottom-right-radius': '3px', + }, + + '&:hover': { + 'background-color': map.TAB_BACK_SELECTED_COLOR, + }, + + 'background-color': map.TOGGLE_BUTTON_BACKGROUND, + + 'border-color': map.TAB_BORDER_COLOR, + + '&[aria-pressed="true"]': { + color: map.BACKGROUND_COLOR, + backgroundColor: map.TOGGLE_BUTTON_SELECTED_BACKGROUND, + }, + }, + diff: { padding: '2px 3px', 'border-radius': '3px', diff --git a/packages/redux-devtools-inspector-monitor/src/utils/filters.ts b/packages/redux-devtools-inspector-monitor/src/utils/filters.ts index a4cfe451..37c1ce80 100644 --- a/packages/redux-devtools-inspector-monitor/src/utils/filters.ts +++ b/packages/redux-devtools-inspector-monitor/src/utils/filters.ts @@ -1,6 +1,8 @@ import type { Action } from 'redux'; import type { LiftedState, PerformAction } from '@redux-devtools/core'; import { ActionForm } from '../redux'; +import { makeSelectRtkQueryActionRegex } from './rtk-query'; +import { createShallowEqualSelector, SelectorsSource } from './selectors'; type ComputedStates = LiftedState< S, @@ -34,7 +36,7 @@ function filterActionsBySearchValue>( ): number[] { const lowerSearchValue = searchValue && searchValue.toLowerCase(); - if (!lowerSearchValue) { + if (!lowerSearchValue || !actionIds.length) { return actionIds; } @@ -48,18 +50,35 @@ function filterActionsBySearchValue>( }); } +function filterOutRtkQueryActions( + actionIds: number[], + actions: Record>>, + rtkQueryRegex: RegExp | null +) { + if (!rtkQueryRegex || actionIds.length === 0) { + return actionIds; + } + + return actionIds.filter((actionId) => { + const type = actions[actionId].action.type; + + return typeof type !== 'string' || !rtkQueryRegex.test(type); + }); +} export interface FilterActionsPayload> { readonly actionIds: number[]; readonly actions: Record>; readonly computedStates: ComputedStates; readonly actionForm: ActionForm; + readonly rtkQueryRegex: RegExp | null; } -export function filterActions>({ +function filterActions>({ actionIds, actions, computedStates, actionForm, + rtkQueryRegex, }: FilterActionsPayload): number[] { let output = filterActionsBySearchValue( actionForm.searchValue, @@ -68,8 +87,44 @@ export function filterActions>({ ); if (actionForm.isNoopFilterActive) { - output = filterStateChangingAction(actionIds, computedStates); + output = filterStateChangingAction(output, computedStates); + } + + if (actionForm.isRtkQueryFilterActive && rtkQueryRegex) { + output = filterOutRtkQueryActions(output, actions, rtkQueryRegex); } return output; } + +export interface SelectFilteredActions> { + (selectorsSource: SelectorsSource): number[]; +} + +/** + * Creates a selector that given `SelectorsSource` returns + * a list of filtered `actionsIds`. + * @returns {number[]} + */ +export function makeSelectFilteredActions< + S, + A extends Action +>(): SelectFilteredActions { + const selectRegex = makeSelectRtkQueryActionRegex(); + + return createShallowEqualSelector( + (selectorsSource: SelectorsSource): FilterActionsPayload => { + const actionForm = selectorsSource.monitorState.actionForm; + const { actionIds, actions, computedStates } = selectorsSource; + + return { + actionIds, + actions, + computedStates, + actionForm, + rtkQueryRegex: selectRegex(selectorsSource), + }; + }, + filterActions + ); +} diff --git a/packages/redux-devtools-inspector-monitor/src/utils/object.ts b/packages/redux-devtools-inspector-monitor/src/utils/object.ts new file mode 100644 index 00000000..98b886f6 --- /dev/null +++ b/packages/redux-devtools-inspector-monitor/src/utils/object.ts @@ -0,0 +1,28 @@ +/** + * Borrowed from `react-redux`. + * @param {unknown} obj The object to inspect. + * @returns {boolean} True if the argument appears to be a plain object. + * @see https://github.com/reduxjs/react-redux/blob/2c7ef25a0704efcf10e41112d88ae9867e946d10/src/utils/isPlainObject.js + */ +export function isPlainObject(obj: unknown): obj is Record { + if (typeof obj !== 'object' || obj === null) { + return false; + } + + const proto = Object.getPrototypeOf(obj); + + if (proto === null) { + return true; + } + + let baseProto = proto; + while (Object.getPrototypeOf(baseProto) !== null) { + baseProto = Object.getPrototypeOf(baseProto); + } + + return proto === baseProto; +} + +export function identity(val: T): T { + return val; +} diff --git a/packages/redux-devtools-inspector-monitor/src/utils/regexp.ts b/packages/redux-devtools-inspector-monitor/src/utils/regexp.ts new file mode 100644 index 00000000..5901ecb2 --- /dev/null +++ b/packages/redux-devtools-inspector-monitor/src/utils/regexp.ts @@ -0,0 +1,17 @@ +// https://stackoverflow.com/a/9310752 +export function escapeRegExpSpecialCharacter(text: string): string { + return text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&'); +} + +/** + * ```ts + * const entries = ['a', 'b', 'c', 'd.']; + * + * oneOfGroup(entries) // returns "(a|b|c|d\\.)" + * ``` + * @param onOf + * @returns + */ +export function oneOfGroup(onOf: string[]): string { + return `(${onOf.map(escapeRegExpSpecialCharacter).join('|')})`; +} diff --git a/packages/redux-devtools-inspector-monitor/src/utils/rtk-query.ts b/packages/redux-devtools-inspector-monitor/src/utils/rtk-query.ts new file mode 100644 index 00000000..ca9ce37a --- /dev/null +++ b/packages/redux-devtools-inspector-monitor/src/utils/rtk-query.ts @@ -0,0 +1,107 @@ +import { Action } from 'redux'; +import { createSelector } from 'reselect'; +import { isPlainObject } from './object'; +import { oneOfGroup } from './regexp'; +import { createShallowEqualSelector, SelectorsSource } from './selectors'; + +interface RtkQueryApiState { + queries: Record; + mutations: Record; + config: Record; + provided: Record; + subscriptions: Record; +} + +const rtkqueryApiStateKeys: ReadonlyArray = [ + 'queries', + 'mutations', + 'config', + 'provided', + 'subscriptions', +]; + +/** + * Type guard used to select apis from the user store state. + * @param val + * @returns {boolean} + */ +export function isApiSlice(val: unknown): val is RtkQueryApiState { + if (!isPlainObject(val)) { + return false; + } + + for (let i = 0, len = rtkqueryApiStateKeys.length; i < len; i++) { + if (!isPlainObject(val[rtkqueryApiStateKeys[i]])) { + return false; + } + } + + return true; +} + +function getApiReducerPaths(currentUserState: unknown): string[] | null { + if (!isPlainObject(currentUserState)) { + return null; + } + + const userStateKeys = Object.keys(currentUserState); + const output: string[] = []; + + for (const key of userStateKeys) { + if (isApiSlice(currentUserState[key])) { + output.push(key); + } + } + + return output; +} + +const knownRtkQueryActionPrefixes = oneOfGroup([ + 'executeQuery', + 'executeMutation', + 'config', + 'subscriptions', + 'invalidation', + 'mutations', + 'queries', +]); + +/** + * Returns a regex that matches rtk query actions from an array of api + * `reducerPaths`. + * @param reducerPaths list of rtkQuery reducerPaths in user state. + * @returns + */ +function generateRtkQueryActionRegex( + reducerPaths: string[] | null +): RegExp | null { + if (!reducerPaths?.length) { + return null; + } + + return new RegExp( + `^${oneOfGroup(reducerPaths)}/${knownRtkQueryActionPrefixes}` + ); +} + +export interface SelectRTKQueryActionRegex> { + (selectorsSource: SelectorsSource): RegExp | null; +} + +export function makeSelectRtkQueryActionRegex< + S, + A extends Action +>(): SelectRTKQueryActionRegex { + const selectApiReducerPaths = createSelector( + (source: SelectorsSource) => + source.computedStates[source.currentStateIndex].state, + getApiReducerPaths + ); + + const selectRtkQueryActionRegex = createShallowEqualSelector( + selectApiReducerPaths, + generateRtkQueryActionRegex + ); + + return selectRtkQueryActionRegex; +} diff --git a/packages/redux-devtools-inspector-monitor/src/utils/selectors.ts b/packages/redux-devtools-inspector-monitor/src/utils/selectors.ts new file mode 100644 index 00000000..790e83dc --- /dev/null +++ b/packages/redux-devtools-inspector-monitor/src/utils/selectors.ts @@ -0,0 +1,55 @@ +import { shallowEqual } from 'react-redux'; +import { Action } from 'redux'; +import type { LiftedState, PerformAction } from '@redux-devtools/core'; +import { createSelectorCreator, defaultMemoize } from 'reselect'; +import { DevtoolsInspectorState } from '../redux'; +import { DevtoolsInspectorProps } from '../DevtoolsInspector'; + +/** + * @see https://github.com/reduxjs/reselect#customize-equalitycheck-for-defaultmemoize + */ +export const createShallowEqualSelector = createSelectorCreator( + defaultMemoize, + shallowEqual +); + +type ComputedStates = LiftedState< + S, + Action, + unknown +>['computedStates']; + +export interface SelectorsSource> { + readonly actionIds: number[]; + readonly actions: Record>; + readonly computedStates: ComputedStates; + readonly monitorState: DevtoolsInspectorState; + readonly currentStateIndex: number; +} + +export function computeSelectorSource>( + props: DevtoolsInspectorProps, + previous: SelectorsSource | null = null +): SelectorsSource { + const { + computedStates, + currentStateIndex, + monitorState, + stagedActionIds, + actionsById, + } = props; + + const next: SelectorsSource = { + currentStateIndex, + monitorState, + computedStates, + actions: actionsById, + actionIds: stagedActionIds, + }; + + if (previous && shallowEqual(next, previous)) { + return previous; + } + + return next; +} diff --git a/packages/redux-devtools-inspector-monitor/src/utils/shallowEqual.ts b/packages/redux-devtools-inspector-monitor/src/utils/shallowEqual.ts new file mode 100644 index 00000000..536ffd1d --- /dev/null +++ b/packages/redux-devtools-inspector-monitor/src/utils/shallowEqual.ts @@ -0,0 +1,47 @@ +function is(x: unknown, y: unknown): boolean { + if (x === y) { + return x !== 0 || y !== 0 || 1 / x === 1 / y; + } else { + return x !== x && y !== y; + } +} + +/** + * Shallow equal algorithm borrowed from react-redux. + * @see https://github.com/reduxjs/react-redux/blob/2c7ef25a0704efcf10e41112d88ae9867e946d10/src/utils/shallowEqual.js + */ +export default function shallowEqual(objA: unknown, objB: unknown): boolean { + if (is(objA, objB)) { + return true; + } + + if ( + typeof objA !== 'object' || + objA === null || + typeof objB !== 'object' || + objB === null + ) { + return false; + } + + const keysA = Object.keys(objA); + const keysB = Object.keys(objB); + + if (keysA.length !== keysB.length) { + return false; + } + + for (let i = 0; i < keysA.length; i++) { + if ( + !Object.prototype.hasOwnProperty.call(objB, keysA[i]) || + !is( + (objA as Record)[keysA[i]], + (objB as Record)[keysA[i]] + ) + ) { + return false; + } + } + + return true; +} diff --git a/yarn.lock b/yarn.lock index 90a9dbce..c2be60a7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3598,6 +3598,7 @@ __metadata: dependencies: "@redux-devtools/core": ^3.9.0 "@redux-devtools/dock-monitor": ^1.4.0 + "@reduxjs/toolkit": ^1.6.0 "@types/dateformat": ^3.0.1 "@types/dragula": ^3.7.0 "@types/hex-rgba": ^1.0.0 @@ -3632,6 +3633,7 @@ __metadata: redux: ^4.1.0 redux-devtools-themes: ^1.0.0 redux-logger: ^3.0.6 + reselect: ^4.0.0 seamless-immutable: ^7.1.4 peerDependencies: "@redux-devtools/core": ^3.7.0 @@ -3730,6 +3732,26 @@ __metadata: languageName: unknown linkType: soft +"@reduxjs/toolkit@npm:^1.6.0": + version: 1.6.1 + resolution: "@reduxjs/toolkit@npm:1.6.1" + dependencies: + immer: ^9.0.1 + redux: ^4.1.0 + redux-thunk: ^2.3.0 + reselect: ^4.0.0 + peerDependencies: + react: ^16.14.0 || ^17.0.0 + react-redux: ^7.2.1 + peerDependenciesMeta: + react: + optional: true + react-redux: + optional: true + checksum: fc7f8211a74e4ccb246870e9f3dddbd2f9a79ce50ed3c3bb68a59af2b279712e0cba0690479416ef3fea6cfcc1e8d257da8e6a4a49a306d38d83d80182329cfb + languageName: node + linkType: hard + "@restart/context@npm:^2.1.4": version: 2.1.4 resolution: "@restart/context@npm:2.1.4" @@ -14909,6 +14931,13 @@ fsevents@^1.2.7: languageName: node linkType: hard +"immer@npm:^9.0.1": + version: 9.0.5 + resolution: "immer@npm:9.0.5" + checksum: a7fa984fa1887a33ce6d44a7a505fd5ac76009336d8b1c99d34f59aaefc28aadf93ab1e5db27513acd15be454a8a89d8151e915d9b0b6e86e72acbd28218410b + languageName: node + linkType: hard + "immutable@npm:^3.8.1 || ^4.0.0-rc.1, immutable@npm:^4.0.0-rc.12": version: 4.0.0-rc.12 resolution: "immutable@npm:4.0.0-rc.12" @@ -23416,6 +23445,13 @@ fsevents@^1.2.7: languageName: node linkType: hard +"reselect@npm:^4.0.0": + version: 4.0.0 + resolution: "reselect@npm:4.0.0" + checksum: 3480930929f673f12962cdde140dce48ea8ba171cd428bb2c7639672e41770bd6b64e935bc0400f47cfa960f617c7ac068c4309527373825d11e27262f08c0a3 + languageName: node + linkType: hard + "resolve-cwd@npm:^2.0.0": version: 2.0.0 resolution: "resolve-cwd@npm:2.0.0"