From 1aa5f76777d3df5a03a0823de6aec5a1ea5c90e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Mon, 26 Sep 2022 18:46:42 +1000 Subject: [PATCH] Preserve the generic nature of getEntityRecord and getEntityRecords through signature currying in `@wordpress/data`. Declare GetEntityRecord as a *callable interface* that is callable as usually, but also ships another signature without the state argument. This works around a TypeScript limitation that doesn't allow currying generic functions: ```ts type CurriedState = F extends ( state: any, ...args: infer P ) => infer R ? ( ...args: P ) => R : F; type Selector = ( state: any, kind: K, key: K extends string ? 'string value' : false ) => K; type BadlyInferredSignature = CurriedState< Selector > // BadlyInferredSignature evaluates to: // (kind: string number, key: false | "string value") => string number ``` The signature without the state parameter shipped as CurriedSignature is used in the return value of `select( coreStore )`. See https://github.com/WordPress/gutenberg/pull/41578 for more details. This commit includes a docgen update to add support for typecasting selectors --- docs/reference-guides/data/data-core.md | 10 +-- packages/autop/README.md | 73 --------------- packages/core-data/README.md | 10 +-- packages/core-data/src/selectors.ts | 99 ++++++++++++++++++--- packages/data/src/types.ts | 65 ++++++++++++-- packages/docgen/lib/get-type-annotation.js | 10 ++- packages/docgen/test/get-type-annotation.js | 77 ++++++++-------- 7 files changed, 204 insertions(+), 140 deletions(-) diff --git a/docs/reference-guides/data/data-core.md b/docs/reference-guides/data/data-core.md index ba500d10c0038f..c32905892edf22 100644 --- a/docs/reference-guides/data/data-core.md +++ b/docs/reference-guides/data/data-core.md @@ -286,14 +286,14 @@ Returns the Entity's records. _Parameters_ -- _state_ `State`: State tree -- _kind_ `string`: Entity kind. -- _name_ `string`: Entity name. -- _query_ `GetRecordsHttpQuery`: Optional terms query. If requesting specific fields, fields must always include the ID. +- _state_ State tree +- _kind_ Entity kind. +- _name_ Entity name. +- _query_ Optional terms query. If requesting specific fields, fields must always include the ID. _Returns_ -- `EntityRecord[] | null`: Records. +- Records. ### getLastEntityDeleteError diff --git a/packages/autop/README.md b/packages/autop/README.md index 76a7515db69f3d..e69de29bb2d1d6 100644 --- a/packages/autop/README.md +++ b/packages/autop/README.md @@ -1,73 +0,0 @@ -# Autop - -JavaScript port of WordPress's automatic paragraph function `autop` and the `removep` reverse behavior. - -## Installation - -Install the module - -```bash -npm install @wordpress/autop --save -``` - -_This package assumes that your code will run in an **ES2015+** environment. If you're using an environment that has limited or no support for such language features and APIs, you should include [the polyfill shipped in `@wordpress/babel-preset-default`](https://github.com/WordPress/gutenberg/tree/HEAD/packages/babel-preset-default#polyfill) in your code._ - -### API - - - -#### autop - -Replaces double line-breaks with paragraph elements. - -A group of regex replaces used to identify text formatted with newlines and -replace double line-breaks with HTML paragraph tags. The remaining line- -breaks after conversion become `
` tags, unless br is set to 'false'. - -_Usage_ - -```js -import { autop } from '@wordpress/autop'; -autop( 'my text' ); // "

my text

" -``` - -_Parameters_ - -- _text_ `string`: The text which has to be formatted. -- _br_ `boolean`: Optional. If set, will convert all remaining line- breaks after paragraphing. Default true. - -_Returns_ - -- `string`: Text which has been converted into paragraph tags. - -#### removep - -Replaces `

` tags with two line breaks. "Opposite" of autop(). - -Replaces `

` tags with two line breaks except where the `

` has attributes. -Unifies whitespace. Indents `

  • `, `
    ` and `
    ` for better readability. - -_Usage_ - -```js -import { removep } from '@wordpress/autop'; -removep( '

    my text

    ' ); // "my text" -``` - -_Parameters_ - -- _html_ `string`: The content from the editor. - -_Returns_ - -- `string`: The content with stripped paragraph tags. - - - -## Contributing to this package - -This is an individual package that's part of the Gutenberg project. The project is organized as a monorepo. It's made up of multiple self-contained software packages, each with a specific purpose. The packages in this monorepo are published to [npm](https://www.npmjs.com/) and used by [WordPress](https://make.wordpress.org/core/) as well as other software projects. - -To find out more about contributing to this package or Gutenberg as a whole, please read the project's main [contributor guide](https://github.com/WordPress/gutenberg/tree/HEAD/CONTRIBUTING.md). - -

    Code is Poetry.

    diff --git a/packages/core-data/README.md b/packages/core-data/README.md index 29057b3abade51..009a343fb4273d 100644 --- a/packages/core-data/README.md +++ b/packages/core-data/README.md @@ -454,14 +454,14 @@ Returns the Entity's records. _Parameters_ -- _state_ `State`: State tree -- _kind_ `string`: Entity kind. -- _name_ `string`: Entity name. -- _query_ `GetRecordsHttpQuery`: Optional terms query. If requesting specific fields, fields must always include the ID. +- _state_ State tree +- _kind_ Entity kind. +- _name_ Entity name. +- _query_ Optional terms query. If requesting specific fields, fields must always include the ID. _Returns_ -- `EntityRecord[] | null`: Records. +- Records. ### getLastEntityDeleteError diff --git a/packages/core-data/src/selectors.ts b/packages/core-data/src/selectors.ts index 79309d84fb98b1..62f3d78b53d74d 100644 --- a/packages/core-data/src/selectors.ts +++ b/packages/core-data/src/selectors.ts @@ -221,6 +221,55 @@ export function getEntityConfig( return find( state.entities.config, { kind, name } ); } +/** + * GetEntityRecord is declared as a *callable interface* with + * two signatures to work around the fact that TypeScript doesn't + * allow currying generic functions: + * + * ```ts + * type CurriedState = F extends ( state: any, ...args: infer P ) => infer R + * ? ( ...args: P ) => R + * : F; + * type Selector = ( + * state: any, + * kind: K, + * key: K extends string ? 'string value' : false + * ) => K; + * type BadlyInferredSignature = CurriedState< Selector > + * // BadlyInferredSignature evaluates to: + * // (kind: string number, key: false | "string value") => string number + * ``` + * + * The signature withot the state parameter shipped as CurriedSignature + * is used in the return value of `dispatch( coreStore )`. + * + * See https://github.com/WordPress/gutenberg/pull/41578 for more details. + */ +export interface GetEntityRecord { + < + EntityRecord extends + | ET.EntityRecord< any > + | Partial< ET.EntityRecord< any > > + >( + state: State, + kind: string, + name: string, + key: EntityRecordKey, + query?: GetRecordsHttpQuery + ): EntityRecord | undefined; + + CurriedSignature: < + EntityRecord extends + | ET.EntityRecord< any > + | Partial< ET.EntityRecord< any > > + >( + kind: string, + name: string, + key: EntityRecordKey, + query?: GetRecordsHttpQuery + ) => EntityRecord | undefined; +} + /** * Returns the Entity's record object by key. Returns `null` if the value is not * yet received, undefined if the value entity is known to not exist, or the @@ -236,7 +285,7 @@ export function getEntityConfig( * @return Record. */ export const getEntityRecord = createSelector( - < + ( < EntityRecord extends | ET.EntityRecord< any > | Partial< ET.EntityRecord< any > > @@ -279,7 +328,7 @@ export const getEntityRecord = createSelector( } return item; - }, + } ) as GetEntityRecord, ( state: State, kind, name, recordId, query ) => { const context = query?.context ?? 'default'; return [ @@ -301,7 +350,7 @@ export const getEntityRecord = createSelector( ] ), ]; } -); +) as any as GetEntityRecord; /** * 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. @@ -414,6 +463,37 @@ export function hasEntityRecords( return Array.isArray( getEntityRecords( state, kind, name, query ) ); } +/** + * GetEntityRecord is declared as a *callable interface* with + * two signatures to work around the fact that TypeScript doesn't + * allow currying generic functions. + * + * @see GetEntityRecord + * @see https://github.com/WordPress/gutenberg/pull/41578 + */ +export interface GetEntityRecords { + < + EntityRecord extends + | ET.EntityRecord< any > + | Partial< ET.EntityRecord< any > > + >( + state: State, + kind: string, + name: string, + query?: GetRecordsHttpQuery + ): EntityRecord[] | null; + + CurriedSignature: < + EntityRecord extends + | ET.EntityRecord< any > + | Partial< ET.EntityRecord< any > > + >( + kind: string, + name: string, + query?: GetRecordsHttpQuery + ) => EntityRecord[] | null; +} + /** * Returns the Entity's records. * @@ -425,16 +505,7 @@ export function hasEntityRecords( * * @return Records. */ -export const getEntityRecords = < - EntityRecord extends - | ET.EntityRecord< any > - | Partial< ET.EntityRecord< any > > ->( - state: State, - kind: string, - name: string, - query?: GetRecordsHttpQuery -): EntityRecord[] | null => { +export const getEntityRecords = ( ( state, kind, name, query ) => { // Queried data state is prepopulated for all known entities. If this is not // assigned for the given parameters, then it is known to not exist. const queriedState = get( state.entities.records, [ @@ -446,7 +517,7 @@ export const getEntityRecords = < return null; } return getQueriedItems( queriedState, query ); -}; +} ) as GetEntityRecords; type DirtyEntityRecord = { title: string; diff --git a/packages/data/src/types.ts b/packages/data/src/types.ts index 0e1b9d7618957e..cec806fd7d7c33 100644 --- a/packages/data/src/types.ts +++ b/packages/data/src/types.ts @@ -77,22 +77,75 @@ export type CurriedSelectorsOf< S > = S extends StoreDescriptor< : never; /** - * Removes the first argument from a function + * Removes the first argument from a function. * - * This is designed to remove the `state` parameter from + * By default, it removes the `state` parameter from * registered selectors since that argument is supplied * by the editor when calling `select(…)`. * * For functions with no arguments, which some selectors * are free to define, returns the original function. + * + * It is possible to manually provide a custom curried signature + * and avoid the automatic inference. When the + * F generic argument passed to this helper extends the + * SelectorWithCustomCurrySignature type, the F['CurriedSignature'] + * property is used verbatim. + * + * This is useful because TypeScript does not correctly remove + * arguments from complex function signatures constrained by + * interdependent generic parameters. + * For more context, see https://github.com/WordPress/gutenberg/pull/41578 */ -export type CurriedState< F > = F extends ( - state: any, - ...args: infer P -) => infer R +type CurriedState< F > = F extends SelectorWithCustomCurrySignature + ? F[ 'CurriedSignature' ] + : F extends ( state: any, ...args: infer P ) => infer R ? ( ...args: P ) => R : F; +/** + * Utility to manually specify curried selector signatures. + * + * It comes handy when TypeScript can't automatically produce the + * correct curried function signature. For example: + * + * ```ts + * type BadlyInferredSignature = CurriedState< + * ( + * state: any, + * kind: K, + * key: K extends string ? 'one value' : false + * ) => K + * > + * // BadlyInferredSignature evaluates to: + * // (kind: string number, key: false "one value") => string number + * ``` + * + * With SelectorWithCustomCurrySignature, we can provide a custom + * signature and avoid relying on TypeScript inference: + * ```ts + * interface MySelectorSignature extends SelectorWithCustomCurrySignature { + * ( + * state: any, + * kind: K, + * key: K extends string ? 'one value' : false + * ): K; + * + * CurriedSignature: ( + * kind: K, + * key: K extends string ? 'one value' : false + * ): K; + * } + * type CorrectlyInferredSignature = CurriedState + * // (kind: K, key: K extends string ? 'one value' : false): K; + * + * For even more context, see https://github.com/WordPress/gutenberg/pull/41578 + * ``` + */ +export interface SelectorWithCustomCurrySignature { + CurriedSignature?: Function; +} + export interface DataRegistry { register: ( store: StoreDescriptor< any > ) => void; } diff --git a/packages/docgen/lib/get-type-annotation.js b/packages/docgen/lib/get-type-annotation.js index c03c3104fd040c..6bf65cc27465a3 100644 --- a/packages/docgen/lib/get-type-annotation.js +++ b/packages/docgen/lib/get-type-annotation.js @@ -405,18 +405,24 @@ function unwrapWrappedSelectors( token ) { return token; } + if ( babelTypes.isTSAsExpression( token ) ) { + // ( ( state, queryId ) => state.queries[ queryId ] ) as any; + // \------------------------------------------------/ CallExpression.expression + return unwrapWrappedSelectors( token.expression ); + } + if ( babelTypes.isCallExpression( token ) ) { // createSelector( ( state, queryId ) => state.queries[ queryId ] ); // \--------------------------------------------/ CallExpression.arguments[0] if ( token.callee.name === 'createSelector' ) { - return token.arguments[ 0 ]; + return unwrapWrappedSelectors( token.arguments[ 0 ] ); } // createRegistrySelector( ( selector ) => ( state, queryId ) => select( 'core/queries' ).get( queryId ) ); // \-----------------------------------------------------------/ CallExpression.arguments[0].body // \---------------------------------------------------------------------------/ CallExpression.arguments[0] if ( token.callee.name === 'createRegistrySelector' ) { - return token.arguments[ 0 ].body; + return unwrapWrappedSelectors( token.arguments[ 0 ].body ); } } } diff --git a/packages/docgen/test/get-type-annotation.js b/packages/docgen/test/get-type-annotation.js index 13ff923f229906..fdf114dfd1f674 100644 --- a/packages/docgen/test/get-type-annotation.js +++ b/packages/docgen/test/get-type-annotation.js @@ -394,46 +394,53 @@ describe( 'Type annotations', () => { } ); describe( 'statically-wrapped function exceptions', () => { - it( 'should find types for inner function with `createSelector`', () => { - const { tokens } = engine( - 'test.ts', - `/** - * Returns the number of things - * - * @param state - stores all the things - */ - export const getCount = createSelector( ( state: string[] ) => state.length ); - ` + const getStateArgType = ( code ) => { + const { tokens } = engine( 'test.ts', code ); + return getTypeAnnotation( + { tag: 'param', name: 'state' }, + tokens[ 0 ], + 0 ); + }; - expect( - getTypeAnnotation( - { tag: 'param', name: 'state' }, - tokens[ 0 ], - 0 - ) - ).toBe( 'string[]' ); + const docString = `/** + * Returns the number of things + * + * @param state - stores all the things + */`; + it( 'should find types for a typecasted function', () => { + const code = `${ docString } + export const getCount = ( state: string[] ) => state.length; + `; + expect( getStateArgType( code ) ).toBe( 'string[]' ); } ); - it( 'should find types for inner function with `createRegistrySelector`', () => { - const { tokens } = engine( - 'test.ts', - `/** - * Returns the number of things - * - * @param state - stores all the things - */ - export const getCount = createRegistrySelector( ( select ) => ( state: number ) => state ); - ` - ); + it( 'should find types for a doubly typecasted function', () => { + const code = `${ docString } + export const getCount = ( ( state: string[] ) => state.length ) as any as any; + `; + expect( getStateArgType( code ) ).toBe( 'string[]' ); + } ); - expect( - getTypeAnnotation( - { tag: 'param', name: 'state' }, - tokens[ 0 ], - 0 - ) - ).toBe( 'number' ); + it( 'should find types for inner function with `createSelector`', () => { + const code = `${ docString } + export const getCount = createSelector( ( state: string[] ) => state.length ); + `; + expect( getStateArgType( code ) ).toBe( 'string[]' ); + } ); + + it( 'should find types for inner typecasted function with `createSelector`', () => { + const code = `${ docString } + export const getCount = createSelector( (( state: string[] ) => state.length) as any ); + `; + expect( getStateArgType( code ) ).toBe( 'string[]' ); + } ); + + it( 'should find types for inner function with `createRegistrySelector`', () => { + const code = `${ docString } + export const getCount = createRegistrySelector( ( select ) => ( state: number ) => state ); + `; + expect( getStateArgType( code ) ).toBe( 'number' ); } ); } ); } );