Skip to content

Commit f73058c

Browse files
authored
Components: Add contextConnectWithoutRef() to bypass ref forwarding (#43611)
* Remove unused memo option * Clean up ts-expect-errors * Force check two params * Check contextConnect correctness with TS * Add tests * Add more TS tests and fixup types * Add comment * Move conditional upstream
1 parent 2ea66cf commit f73058c

File tree

2 files changed

+113
-31
lines changed

2 files changed

+113
-31
lines changed

packages/components/src/ui/context/context-connect.ts

+58-31
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type { ForwardedRef, ReactChild, ReactNode } from 'react';
66
/**
77
* WordPress dependencies
88
*/
9-
import { forwardRef, memo } from '@wordpress/element';
9+
import { forwardRef } from '@wordpress/element';
1010
import warn from '@wordpress/warning';
1111

1212
/**
@@ -16,42 +16,74 @@ import { CONNECT_STATIC_NAMESPACE } from './constants';
1616
import { getStyledClassNameFromKey } from './get-styled-class-name-from-key';
1717
import type { WordPressComponentFromProps } from '.';
1818

19+
type AcceptsTwoArgs<
20+
F extends ( ...args: any ) => any,
21+
ErrorMessage = never
22+
> = Parameters< F >[ 'length' ] extends 2 ? {} : ErrorMessage;
23+
1924
type ContextConnectOptions = {
20-
/** Defaults to `false`. */
21-
memo?: boolean;
25+
forwardsRef?: boolean;
2226
};
2327

2428
/**
2529
* Forwards ref (React.ForwardRef) and "Connects" (or registers) a component
2630
* within the Context system under a specified namespace.
2731
*
28-
* This is an (experimental) evolution of the initial connect() HOC.
29-
* The hope is that we can improve render performance by removing functional
30-
* component wrappers.
32+
* @param Component The component to register into the Context system.
33+
* @param namespace The namespace to register the component under.
34+
* @return The connected WordPressComponent
35+
*/
36+
export function contextConnect<
37+
C extends ( props: any, ref: ForwardedRef< any > ) => JSX.Element | null
38+
>(
39+
Component: C &
40+
AcceptsTwoArgs<
41+
C,
42+
'Warning: Your component function does not take a ref as the second argument. Did you mean to use `contextConnectWithoutRef`?'
43+
>,
44+
namespace: string
45+
) {
46+
return _contextConnect( Component, namespace, { forwardsRef: true } );
47+
}
48+
49+
/**
50+
* "Connects" (or registers) a component within the Context system under a specified namespace.
51+
* Does not forward a ref.
3152
*
3253
* @param Component The component to register into the Context system.
3354
* @param namespace The namespace to register the component under.
34-
* @param options
3555
* @return The connected WordPressComponent
3656
*/
37-
export function contextConnect< P >(
38-
Component: ( props: P, ref: ForwardedRef< any > ) => JSX.Element | null,
39-
namespace: string,
40-
options: ContextConnectOptions = {}
41-
): WordPressComponentFromProps< P > {
42-
const { memo: memoProp = false } = options;
57+
export function contextConnectWithoutRef< P >(
58+
Component: ( props: P ) => JSX.Element | null,
59+
namespace: string
60+
) {
61+
return _contextConnect( Component, namespace );
62+
}
4363

44-
let WrappedComponent = forwardRef( Component );
45-
if ( memoProp ) {
46-
// @ts-ignore
47-
WrappedComponent = memo( WrappedComponent );
48-
}
64+
// This is an (experimental) evolution of the initial connect() HOC.
65+
// The hope is that we can improve render performance by removing functional
66+
// component wrappers.
67+
function _contextConnect<
68+
C extends ( props: any, ref: ForwardedRef< any > ) => JSX.Element | null,
69+
O extends ContextConnectOptions
70+
>(
71+
Component: C,
72+
namespace: string,
73+
options?: O
74+
): WordPressComponentFromProps<
75+
Parameters< C >[ 0 ],
76+
O[ 'forwardsRef' ] extends true ? true : false
77+
> {
78+
const WrappedComponent = options?.forwardsRef
79+
? forwardRef< any, Parameters< C >[ 0 ] >( Component )
80+
: Component;
4981

5082
if ( typeof namespace === 'undefined' ) {
5183
warn( 'contextConnect: Please provide a namespace' );
5284
}
5385

54-
// @ts-ignore internal property
86+
// @ts-expect-error internal property
5587
let mergedNamespace = WrappedComponent[ CONNECT_STATIC_NAMESPACE ] || [
5688
namespace,
5789
];
@@ -66,18 +98,13 @@ export function contextConnect< P >(
6698
mergedNamespace = [ ...mergedNamespace, namespace ];
6799
}
68100

69-
WrappedComponent.displayName = namespace;
70-
71-
// @ts-ignore internal property
72-
WrappedComponent[ CONNECT_STATIC_NAMESPACE ] = [
73-
...new Set( mergedNamespace ),
74-
];
75-
76-
// @ts-ignore WordPressComponent property
77-
WrappedComponent.selector = `.${ getStyledClassNameFromKey( namespace ) }`;
78-
79-
// @ts-ignore
80-
return WrappedComponent;
101+
// @ts-expect-error We can't rely on inferred types here because of the
102+
// `as` prop polymorphism we're handling in https://github.com/WordPress/gutenberg/blob/9620bae6fef4fde7cc2b7833f416e240207cda29/packages/components/src/ui/context/wordpress-component.ts#L32-L33
103+
return Object.assign( WrappedComponent, {
104+
[ CONNECT_STATIC_NAMESPACE ]: [ ...new Set( mergedNamespace ) ],
105+
displayName: namespace,
106+
selector: `.${ getStyledClassNameFromKey( namespace ) }`,
107+
} );
81108
}
82109

83110
/**
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/**
2+
* External dependencies
3+
*/
4+
import type { ForwardedRef } from 'react';
5+
6+
/**
7+
* Internal dependencies
8+
*/
9+
import { contextConnect, contextConnectWithoutRef } from '../context-connect';
10+
import type { WordPressComponentProps } from '../wordpress-component';
11+
12+
// Static TypeScript tests
13+
describe( 'ref forwarding', () => {
14+
const ComponentWithRef = (
15+
props: WordPressComponentProps< {}, 'div' >,
16+
ref: ForwardedRef< any >
17+
) => <div { ...props } ref={ ref } />;
18+
const ComponentWithoutRef = (
19+
props: WordPressComponentProps< {}, 'div' >
20+
) => <div { ...props } />;
21+
22+
it( 'should not trigger a TS error if components are passed to the correct connect* functions', () => {
23+
contextConnect( ComponentWithRef, 'Foo' );
24+
contextConnectWithoutRef( ComponentWithoutRef, 'Foo' );
25+
} );
26+
27+
it( 'should trigger a TS error if components are passed to the wrong connect* functions', () => {
28+
// Wrapped in a thunk because React.forwardRef() will throw a console warning if this is executed
29+
// eslint-disable-next-line no-unused-expressions
30+
() => {
31+
// @ts-expect-error This should error
32+
contextConnect( ComponentWithoutRef, 'Foo' );
33+
};
34+
35+
// @ts-expect-error This should error
36+
contextConnectWithoutRef( ComponentWithRef, 'Foo' );
37+
} );
38+
39+
it( 'should result in a component with the correct prop types', () => {
40+
const AcceptsRef = contextConnect( ComponentWithRef, 'Foo' );
41+
42+
<AcceptsRef ref={ null } />;
43+
44+
// @ts-expect-error An unaccepted prop should trigger an error
45+
<AcceptsRef foo={ null } />;
46+
47+
const NoRef = contextConnectWithoutRef( ComponentWithoutRef, 'Foo' );
48+
49+
// @ts-expect-error The ref prop should trigger an error
50+
<NoRef ref={ null } />;
51+
52+
// @ts-expect-error An unaccepted prop should trigger an error
53+
<NoRef foo={ null } />;
54+
} );
55+
} );

0 commit comments

Comments
 (0)