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

Fix resolver resolution status key types mismatch causing unwanted resolver calls for identical state data #52120

Merged
merged 33 commits into from
Oct 16, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
6bcec6e
Normalize
getdave Jun 29, 2023
9f8f093
Add optional method property to normalize arguments before usage
getdave Jun 29, 2023
355ee98
Check for ID-like keys rather than numeric things
getdave Jun 29, 2023
b0598a2
Extract util and update to use suggestion from Code Review
getdave Jun 30, 2023
99419ce
Test for util
getdave Jul 4, 2023
82352d7
Add tests for normalizeArgs
getdave Jul 4, 2023
49eea82
Fix bug with normalizeArgs called even if there are no args
getdave Jul 4, 2023
f569a98
Add direct test on `normalizeArgs` method of getEntityRecord
getdave Jul 4, 2023
cd31934
Allow defining normalize method on selector and call for both selecto…
getdave Jul 4, 2023
775f32b
Move normalization function to getEntityRecord selector
getdave Jul 4, 2023
240075b
Move normalization functino to getEntityRecord selector
getdave Jul 4, 2023
f9a3fb7
Add rough outline of documentation
getdave Jul 4, 2023
728b5f7
Add test to ensure normalization is applied to selector even without …
getdave Jul 4, 2023
3c43549
Improve documentation to better explain concept of normalizeArgs
getdave Jul 5, 2023
4c5aa43
Correct grammar
getdave Jul 5, 2023
959fe56
Apply types optimisation from code review
getdave Jul 5, 2023
7aa49ed
Extract conditionals to helper function
getdave Jul 5, 2023
3965284
Document getEntityRecord normalization function
getdave Jul 5, 2023
54d76c7
Add type defs to normalization function
getdave Jul 5, 2023
0d5856e
Fix nits in README
getdave Jul 5, 2023
7683ab3
Remove new line
getdave Jul 5, 2023
7b4cdfb
Add test for arguments length before invoking normalization function
getdave Jul 5, 2023
6702752
Remove unwanted comment
getdave Jul 5, 2023
be322a4
Addition to docs as per code review
getdave Jul 5, 2023
c178aa5
Fix bug with metadata selector args not being normalized
getdave Jul 6, 2023
f375fd7
Add tests for normalization to metadata selector tests
getdave Jul 6, 2023
d4899d2
Add test case for decimals
getdave Jul 7, 2023
9ad9744
Prefix with __unstable to denote non “settled” approach amongst contr…
getdave Jul 12, 2023
4598fa0
Remove check for args and conditionally access args index
getdave Jul 12, 2023
3a1dbb5
Simplify coercing to number
getdave Jul 12, 2023
2c5dc7a
Revert confusing DRY typescript
getdave Jul 12, 2023
b0b5b45
Fix renaming selector to `unstable`
getdave Jul 12, 2023
5d2c7b3
Copy to new args
getdave Jul 12, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions packages/core-data/src/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
getNormalizedCommaSeparable,
isRawAttribute,
setNestedValue,
isNumericID,
} from './utils';
import type * as ET from './entity-types';
import type { UndoManager } from '@wordpress/undo-manager';
Expand Down Expand Up @@ -95,6 +96,13 @@ type Optional< T > = T | undefined;
*/
type GetRecordsHttpQuery = Record< string, any >;

/**
* Arguments for EntityRecord selectors.
*/
type EntityRecordArgs =
| [ string, string, EntityRecordKey ]
| [ string, string, EntityRecordKey, GetRecordsHttpQuery ];

/**
* Shared reference to an empty object for cases where it is important to avoid
* returning a new object reference on every invocation, as in a connected or
Expand Down Expand Up @@ -292,6 +300,7 @@ export interface GetEntityRecord {
key: EntityRecordKey,
query?: GetRecordsHttpQuery
) => EntityRecord | undefined;
__unstableNormalizeArgs?: ( args: EntityRecordArgs ) => EntityRecordArgs;
}

/**
Expand Down Expand Up @@ -365,6 +374,24 @@ export const getEntityRecord = createSelector(
}
) as GetEntityRecord;

/**
* Normalizes `recordKey`s that look like numeric IDs to numbers.
*
* @param args EntityRecordArgs the selector arguments.
* @return EntityRecordArgs the normalized arguments.
*/
getEntityRecord.__unstableNormalizeArgs = (
args: EntityRecordArgs
): EntityRecordArgs => {
const newArgs = [ ...args ] as EntityRecordArgs;
const recordKey = newArgs?.[ 2 ];

// If recordKey looks to be a numeric ID then coerce to number.
newArgs[ 2 ] = isNumericID( recordKey ) ? Number( recordKey ) : recordKey;

return newArgs;
};

/**
* Returns the Entity's record object by key. Doesn't trigger a resolver nor requests the entity records from the API if the entity record isn't available in the local state.
*
Expand Down
23 changes: 23 additions & 0 deletions packages/core-data/src/test/selectors.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,29 @@ describe.each( [
[ getEntityRecord ],
[ __experimentalGetEntityRecordNoResolver ],
] )( '%p', ( selector ) => {
describe( 'normalizing Post ID passed as recordKey', () => {
it( 'normalizes any Post ID recordKey argument to a Number via `__unstableNormalizeArgs` method', async () => {
const normalized = getEntityRecord.__unstableNormalizeArgs( [
'postType',
'some_post',
'123',
] );
expect( normalized ).toEqual( [ 'postType', 'some_post', 123 ] );
} );

it( 'does not normalize recordKey argument unless it is a Post ID', async () => {
const normalized = getEntityRecord.__unstableNormalizeArgs( [
'postType',
'some_post',
'i-am-a-slug-with-a-number-123',
] );
expect( normalized ).toEqual( [
'postType',
'some_post',
'i-am-a-slug-with-a-number-123',
] );
} );
} );
it( 'should return undefined for unknown entity kind, name', () => {
const state = deepFreeze( {
entities: {
Expand Down
1 change: 1 addition & 0 deletions packages/core-data/src/utils/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ export { default as withWeakMapCache } from './with-weak-map-cache';
export { default as isRawAttribute } from './is-raw-attribute';
export { default as setNestedValue } from './set-nested-value';
export { default as getNestedValue } from './get-nested-value';
export { default as isNumericID } from './is-numeric-id';
10 changes: 10 additions & 0 deletions packages/core-data/src/utils/is-numeric-id.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/**
* Checks argument to determine if it's a numeric ID.
* For example, '123' is a numeric ID, but '123abc' is not.
*
* @param {any} id the argument to determine if it's a numeric ID.
* @return {boolean} true if the string is a numeric ID, false otherwise.
*/
export default function isNumericID( id ) {
return /^\s*\d+\s*$/.test( id );
}
22 changes: 22 additions & 0 deletions packages/core-data/src/utils/test/is-numeric-id.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* Internal dependencies
*/
import isNumericID from '../is-numeric-id';

describe( 'isNumericID', () => {
test.each( [
[ true, '12345' ],
[ true, '42' ],
[ true, ' 42 ' ],
[ false, 'abc123' ],
[ false, '123abc' ],
[ false, '' ],
[ false, '123-abc' ],
[ false, 'abc-123' ],
[ false, '42-test-123' ],
[ false, '3.42' ],
[ true, 123 ],
] )( `should return %s for input "%s"`, ( expected, input ) => {
expect( isNumericID( input ) ).toBe( expected );
} );
} );
80 changes: 80 additions & 0 deletions packages/data/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1127,6 +1127,86 @@ _Returns_

- `boolean`: Whether resolution is in progress.

### Normalizing Selector Arguments
getdave marked this conversation as resolved.
Show resolved Hide resolved

In specific circumstances it may be necessary to normalize the arguments passed to a given _call_ of a selector/resolver pairing.
getdave marked this conversation as resolved.
Show resolved Hide resolved

Each resolver has [its resolution status cached in an internal state](https://github.com/WordPress/gutenberg/blob/e244388d8669618b76c966cc33d48df9156c2db6/packages/data/src/redux-store/metadata/reducer.ts#L39) where the [key is the arguments supplied to the selector](https://github.com/WordPress/gutenberg/blob/e244388d8669618b76c966cc33d48df9156c2db6/packages/data/src/redux-store/metadata/utils.ts#L48) at _call_ time.

For example for a selector with a single argument, the related resolver would generate a cache key of: `[ 123 ]`.

[This cache is used to determine the resolution status of a given resolver](https://github.com/WordPress/gutenberg/blob/e244388d8669618b76c966cc33d48df9156c2db6/packages/data/src/redux-store/metadata/selectors.js#L22-L29) which is used to [avoid unwanted additional invocations of resolvers](https://github.com/WordPress/gutenberg/blob/e244388d8669618b76c966cc33d48df9156c2db6/packages/data/src/redux-store/index.js#L469-L474) (which often undertake "expensive" operations such as network requests).

As a result it's important that arguments remain _consistent_ when calling the selector. For example, by _default_ these two calls will not be cached using the same key, even though they are likely identical:

```js
// Arg as number
getSomeDataById( 123 );

// Arg as string
getSomeDataById( '123' );
```

This is an opportunity to utilize the `__unstableNormalizeArgs` property to guarantee consistency by protecting callers from passing incorrect types.

#### Example

The _3rd_ argument of the following selector is intended to be a `Number`:

```js
const getItemsSelector = ( name, type, id ) => {
return state.items[ name ][ type ][ id ] || null;
};
```

However, it is possible that the `id` parameter will be passed as a `String`. In this case, the `__unstableNormalizeArgs` method (property) can be defined on the _selector_ to coerce the arguments to the desired type even if they are provided "incorrectly":

```js
// Define normalization method.
getItemsSelector.__unstableNormalizeArgs = ( args ) {
// "id" argument at the 2nd index
if (args[2] && typeof args[2] === 'string' ) {
args[2] === Number(args[2]);
}

return args;
}
```

With this in place the following code will behave consistently:

```js
const getItemsSelector = ( name, type, id ) => {
// here 'id' is now guaranteed to be a number.
return state.items[ name ][ type ][ id ] || null;
};

const getItemsResolver = ( name, type, id ) => {
// 'id' is also guaranteed to be a number in the resolver.
return {};
};

registry.registerStore( 'store', {
// ...
selectors: {
getItems: getItemsSelector,
},
resolvers: {
getItems: getItemsResolver,
},
} );

// Call with correct number type.
registry.select( 'store' ).getItems( 'foo', 'bar', 54 );

// Call with the wrong string type, **but** here we have avoided an
// wanted resolver call because '54' is guaranteed to have been
// coerced to a number by the `__unstableNormalizeArgs` method.
registry.select( 'store' ).getItems( 'foo', 'bar', '54' );
```

Ensuring consistency of arguments for a given selector call is [an important optimization to help improve performance in the data layer](https://github.com/WordPress/gutenberg/pull/52120). However, this type of problem can be usually be avoided by ensuring selectors don't use variable types for their arguments.

## Going further

- [What is WordPress Data?](https://unfoldingneurons.com/2020/what-is-wordpress-data/)
Expand Down
46 changes: 44 additions & 2 deletions packages/data/src/redux-store/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -235,11 +235,19 @@ export default function createReduxStore( key, options ) {
selector.registry = registry;
}
const boundSelector = ( ...args ) => {
args = normalize( selector, args );
const state = store.__unstableOriginalGetState();
return selector( state.root, ...args );
};

// Expose normalization method on the bound selector
// in order that it can be called when fullfilling
// the resolver.
boundSelector.__unstableNormalizeArgs =
selector.__unstableNormalizeArgs;

const resolver = resolvers[ selectorName ];

if ( ! resolver ) {
boundSelector.hasResolver = false;
return boundSelector;
Expand All @@ -254,10 +262,24 @@ export default function createReduxStore( key, options ) {
);
}

function bindMetadataSelector( selector ) {
function bindMetadataSelector( metaDataSelector ) {
const boundSelector = ( ...args ) => {
const state = store.__unstableOriginalGetState();
return selector( state.metadata, ...args );

const originalSelectorName = args && args[ 0 ];
const originalSelectorArgs = args && args[ 1 ];
const targetSelector =
options?.selectors?.[ originalSelectorName ];
Copy link
Member

Choose a reason for hiding this comment

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

This will not work with private selectors.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I knew it. The complexity of the code was a clue. Having to jump through so many hoops.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Looking at this again. I don't yet understand why it doesn't work for private selectors.

First step would be writing a test that fails based on what @jsnajdr said above.

With that in place we can fix for private selectors.


// Normalize the arguments passed to the target selector.
if ( originalSelectorName && targetSelector ) {
args[ 1 ] = normalize(
targetSelector,
originalSelectorArgs
);
}

return metaDataSelector( state.metadata, ...args );
Copy link
Member

Choose a reason for hiding this comment

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

I wish this code could be moved into the affected metadata selectors themselves. Actually the only one that needs the normalization is getResolutionState.

Copy link
Member

Choose a reason for hiding this comment

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

One idea: the metadata selectors and the metadata state are private, not exposed in any public API, so we can change their parameters. As state, we could pass an object like:

{
  metadata, // the actual state object
  normalizeArgs( selectorName, args ), // a new helper function
}

The bindMetadataSelector function has access to all registered selectors, their names and normalizeArgs functions. So, it can implement the normalizeArgs helper as:

function normalizeArgs( selectorName, args ) {
  if ( allSelectors[ selectorName ]?.normalizeArgs ) {
    return allSelectors[ selectorName ].normalizeArgs( args );
  }
  return args;
}

and getResolutionState can be implemented as:

function getResolutionState( state, selectorName, args ) {
  return state.metadata[ selectorName ].get( state.normalizeArgs( selectorName, args ) );
}

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This might be a "simpler" approach. I'll take a look.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Looking here against. The complexity still exists in the bindMetadataSelector but we're just making the getResolutionState aware of the complexity as well.

I'm probably missing something but I'd like to understand more before I proceed with further refactor.

};
boundSelector.hasResolver = false;
return boundSelector;
Expand Down Expand Up @@ -604,9 +626,29 @@ function mapSelectorWithResolver(
}

const selectorResolver = ( ...args ) => {
args = normalize( selector, args );
fulfillSelector( args );
return selector( ...args );
};
selectorResolver.hasResolver = true;
return selectorResolver;
}

/**
* Applies selector's normalization function to the given arguments
* if it exists.
*
* @param {Object} selector The selector potentially with a normalization method property.
* @param {Array} args selector arguments to normalize.
* @return {Array} Potentially normalized arguments.
*/
function normalize( selector, args ) {
if (
selector.__unstableNormalizeArgs &&
typeof selector.__unstableNormalizeArgs === 'function' &&
args?.length
) {
return selector.__unstableNormalizeArgs( args );
}
return args;
}
40 changes: 38 additions & 2 deletions packages/data/src/redux-store/metadata/test/selectors.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
*/
import { createRegistry } from '@wordpress/data';

const getFooSelector = ( state ) => state;

const testStore = {
reducer: ( state = null, action ) => {
if ( action.type === 'RECEIVE' ) {
Expand All @@ -12,7 +14,7 @@ const testStore = {
return state;
},
selectors: {
getFoo: ( state ) => state,
getFoo: getFooSelector,
},
};

Expand Down Expand Up @@ -55,7 +57,7 @@ describe( 'getIsResolving', () => {
expect( result ).toBe( true );
} );

it( 'should normalize args ard return the right value', () => {
it( 'should normalize args and return the right value', () => {
registry.dispatch( 'testStore' ).startResolution( 'getFoo', [] );
const { getIsResolving } = registry.select( 'testStore' );

Expand Down Expand Up @@ -443,3 +445,37 @@ describe( 'countSelectorsByStatus', () => {
expect( result1 ).not.toBe( result2 );
} );
} );

describe( 'Selector arguments normalization', () => {
let registry;
beforeEach( () => {
registry = createRegistry();
registry.registerStore( 'testStore', testStore );
} );

it( 'should call normalization method on target selector if exists', () => {
const normalizationFunction = jest.fn( ( args ) => {
return args.map( Number );
} );
getFooSelector.__unstableNormalizeArgs = normalizationFunction;

registry.dispatch( 'testStore' ).startResolution( 'getFoo', [ 123 ] );
const { getIsResolving, hasStartedResolution, hasFinishedResolution } =
registry.select( 'testStore' );

expect( getIsResolving( 'getFoo', [ '123' ] ) ).toBe( true );
expect( normalizationFunction ).toHaveBeenCalledWith( [ '123' ] );

expect( hasStartedResolution( 'getFoo', [ '123' ] ) ).toBe( true );
expect( normalizationFunction ).toHaveBeenCalledWith( [ '123' ] );

expect( normalizationFunction ).toHaveBeenCalledTimes( 2 );

registry.dispatch( 'testStore' ).finishResolution( 'getFoo', [ 123 ] );

expect( hasFinishedResolution( 'getFoo', [ '123' ] ) ).toBe( true );
expect( normalizationFunction ).toHaveBeenCalledWith( [ '123' ] );

getFooSelector.__unstableNormalizeArgs = undefined;
} );
} );
Loading
Loading