Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[DataGrid] Fix memoized selectors with arguments #15215

Open
wants to merge 9 commits into
base: master
Choose a base branch
from

Conversation

MBilalShafi
Copy link
Member

As reported by @KenanYusuf, memoized selectors with arguments weren't working properly, this PR aims to fix that.

Extracted from #15200 to separate from the BC.

@MBilalShafi MBilalShafi added core Infrastructure work going on behind the scenes component: data grid This is the name of the generic UI component, not the React module! labels Oct 31, 2024
@mui-bot
Copy link

mui-bot commented Oct 31, 2024

Deploy preview: https://deploy-preview-15215--material-ui-x.netlify.app/

Generated by 🚫 dangerJS against 2f4463d

@MBilalShafi MBilalShafi added needs cherry-pick The PR should be cherry-picked to master after merge bug 🐛 Something doesn't work labels Oct 31, 2024
@MBilalShafi MBilalShafi marked this pull request as ready for review October 31, 2024 10:57
@MBilalShafi MBilalShafi requested review from a team and romgrk October 31, 2024 10:58
Copy link
Contributor

@KenanYusuf KenanYusuf left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tested on my working branch using memoized v8 selectors and it is working as expected 👍

Copy link
Contributor

@KenanYusuf KenanYusuf left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@MBilalShafi Spotted an issue with the types for selectors created using createSelectorMemoizedV8:

When calling the selector directly, it seems to require an args arg even if additional args are not used by the selector.

Screenshot 2024-10-31 at 14 54 39

Selector:

export const gridColumnDefinitionsSelectorV8 = createSelectorMemoizedV8(
  gridColumnFieldsSelectorV8,
  gridColumnLookupSelectorV8,
  (allFields, lookup) => allFields.map((field) => lookup[field]),
);

(ignore all the selectors being suffixed with v8, it's for testing purposes)

@MBilalShafi MBilalShafi requested review from KenanYusuf and romgrk and removed request for romgrk November 1, 2024 13:45
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When calling the selector directly, it seems to require an args arg even if additional args are not used by the selector.

@KenanYusuf There isn't a perfect fix for it atm, I've settled on making args an optional argument, which would solve the problem you mentioned but not warn now if a selector requires arguments but isn't passed. To solve this problem I think we'll have to go back to this approach.
I guess the tradeoff is liveable since not providing arguments where required should generate runtime errors for the users to fix them anyways.

Thoughts?
CC @mui/xgrid

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess the tradeoff is liveable since not providing arguments where required should generate runtime errors for the users to fix them anyways.

I agree on this.

It may be a separate issue, but given the following selectors:

// This selector has a selector arg
export const gridVisibleColumnDefinitionsSelector = createSelectorMemoizedV8(
  gridColumnDefinitionsSelector,
  gridColumnVisibilityModelSelector,
  gridListColumnSelector,
  (columns, columnVisibilityModel, listColumn, listView?: boolean) => {
    if (listView) {
      return listColumn ? [listColumn] : [];
    }
    return columns.filter((column) => columnVisibilityModel[column.field] !== false);
  },
);

// Uses the previous selector, but does not use the selector arg
export const gridVisibleColumnFieldsSelector = createSelectorMemoizedV8(
  gridVisibleColumnDefinitionsSelector,
  (visibleColumns) => visibleColumns.map((column) => column.field),
);

I am seeing this error when calling the selector function:

Screenshot 2024-11-04 at 14 00 59
Full error
No overload matches this call.
  Overload 1 of 2, '(apiRef: MutableRefObject<{ state: GridStateCommunity; instanceId: { id: number; }; }>, args?: (GridListColDef & { computedWidth: number; })[] | undefined): string[]', gave the following error.
    Argument of type 'boolean | undefined' is not assignable to parameter of type '(GridListColDef & { computedWidth: number; })[] | undefined'.
      Type 'boolean' is not assignable to type '(GridListColDef & { computedWidth: number; })[]'.
  Overload 2 of 2, '(state: GridStateCommunity, instanceId: { id: number; }): string[]', gave the following error.
    Argument of type 'MutableRefObject<GridPrivateApiCommunity>' is not assignable to parameter of type 'GridStateCommunity'.
      Type 'MutableRefObject<GridPrivateApiCommunity>' is missing the following properties from type 'GridStateCommunity': isRtl, dimensions, rows, visibleRowsLookup, and 19 more.ts(2769)

If I change it to the following, the selector function is typed correctly but the param is unused:

export const gridVisibleColumnFieldsSelector = createSelectorMemoizedV8(
  gridVisibleColumnDefinitionsSelector,
  (visibleColumns, listView?: boolean) => visibleColumns.map((column) => column.field),
);

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's kinda expected. The derived selectors have to have the same arguments (if not more), only then the underlying selectors will be provided with the correct arguments to compute the substate correctly.

gridVisibleColumnDefinitionsSelector requires listView argument and gridVisibleColumnFieldsSelector is derived from it. It should also require listView argument for gridVisibleColumnDefinitionsSelector provide the correct value for the visibleColumns argument.

If multiple selectors require different arguments, we could use an object as argument. For example:

// First selector that requires arguments
const baseSelector = createSelectorMemoized(
	selectorX,
	(selectorXOutput, { baseSelectorArgs }) => {
		// compute sub-state and return
		const baseSelectorOutput = computeValue(selectorXOutput, baseSelectorArgs);
		return baseSelectorOutput;
	}
);

// Another selector that derives `baseSelector` and also requires some more arguments
const derivedSelector = createSelectorMemoized(
	baseSelector,
	(baseSelectorOutput, { baseSelectorArgs, derivedSelectorArgs }) => {
		// This function doesn't itself need `baseSelectorArgs` but it should still make them required
		// so that `baseSelectorOutput` is computed properly
		const derivedSelectorOutput = computeValue(baseSelectorOutput, derivedSelectorArgs);
		return derivedSelectorOutput;
	}
);

// Another selector that derives `derivedSelector` and doesn't require any arguments
const derivedSelector2 = createSelectorMemoized(
	derivedSelector,
	(derivedSelectorOutput, { baseSelectorArgs, derivedSelectorArgs }) => {
		// This function doesn't require any args but it should still `baseSelectorArgs` and `derivedSelectorArgs` make them required
		// so that the underlying selectors are computed properly
		const derivedSelector2Output = computeValue(derivedSelectorOutput);
		return derivedSelector2Output;
	}
);

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess it makes sense, but in scenarios where the selector arg is not used, do we just disable @typescript-eslint/no-unused-vars on the line of the unused param?

Copy link
Member Author

@MBilalShafi MBilalShafi Nov 4, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we could explicitly define the function type to avoid adding an ignore directive and a cleaner code.

How does the following feel:

type BaseSelectorArgs = string;
type DerivedSelectorArgs = string;

type DerivedSelector2OutputFn = (
  derivedSelectorOutput: string,
  args: { derivedSelectorArgs: DerivedSelectorArgs; baseSelectorArgs: BaseSelectorArgs },
) => ReturnType<typeof derivedSelector>;

// Updated `derivedSelector2` from above snippet.
export const derivedSelector2 = createSelectorMemoized(derivedSelector, ((derivedSelectorOutput) => {
  return derivedSelectorOutput;
  }) as DerivedSelector2OutputFn,
);

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It feels quite verbose doing this for each selector that is derived from another with args.

Ideally, derivedSelector2 in the example above would include the typed selector args in the function signature based on the selectors that are passed to it.

The same way that TS knows here that this first param is GridListColDef[] because it has gridVisibleColumnDefinitionsSelector as the first argument of createSelectorMemoizedV8:

Screenshot 2024-11-05 at 09 36 21

I haven't fully wrapped my head around the types for these selector creator functions, so I don't know if it is possible, but that would be the ideal DX I think.

Copy link
Member Author

@MBilalShafi MBilalShafi Nov 6, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@KenanYusuf You are right, it's a bit verbose. It aligns with the choice we made in the selectors with arguments PR, i.e. we should explicitly type the arguments when defining the output function.

I think that having the underlying selectors' output function's arguments type detected automatically won't be possible without the two-step generics, as currently it's only possible to infer the function's return type.

We could introduce an ignore pattern for the @typescript-eslint/no-unused-vars rule:

diff --git a/.eslintrc.js b/.eslintrc.js
index d7b85f4b16..6790094660 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -148,6 +148,12 @@ module.exports = {
         ),
       },
     ],
+    "@typescript-eslint/no-unused-vars": [
+      "warn",
+      {
+        "argsIgnorePattern": "^_",
+      }
+    ],
     // TODO move rule into the main repo once it ha
s upgraded
     '@typescript-eslint/return-await': 'off',
     'no-restricted-imports': 'off',

To allow something like following without the need to add the ignore directive.
(Typescript compiler does it automatically.)

const derivedSelector2 = createSelectorMemoized(
	derivedSelector,
	(derivedSelectorOutput, _args: Args) => {
		return doSomething(derivedSelectorOutput)
	}
);

It will be only needed for the selectors that are derived from some selectors with arguments but they do not require any arguments which I assume would be very less use-cases.

How does adding the eslint ignore pattern sound to reduce verbosity for such use cases? Underscore pattern is widely used and shouldn't contribute to reduced readability.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the context from the initial discussion, makes sense. I think the underscore pattern is a good compromise given the complexities of the alternate solutions. I'm going to continue testing these changes today 👍

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug 🐛 Something doesn't work component: data grid This is the name of the generic UI component, not the React module! core Infrastructure work going on behind the scenes needs cherry-pick The PR should be cherry-picked to master after merge
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants