diff --git a/.gitattributes b/.gitattributes index 0dd26ee2c3..31d27a0985 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1,4 @@ amd/* linguist-generated=true -es/* linguist-generated=true +es/**/* linguist-generated=true dist/* linguist-generated=true -lib/* linguist-generated=true +lib/**/* linguist-generated=true diff --git a/src/ButtonGroup.js b/src/ButtonGroup.js index 1ea2a68845..84229f5b17 100644 --- a/src/ButtonGroup.js +++ b/src/ButtonGroup.js @@ -1,7 +1,7 @@ import classNames from 'classnames'; import React from 'react'; import PropTypes from 'prop-types'; -import all from 'prop-types-extra/lib/all'; +import warning from 'warning'; import Button from './Button'; import { @@ -19,13 +19,7 @@ const propTypes = { * Display block buttons; only useful when used with the "vertical" prop. * @type {bool} */ - block: all( - PropTypes.bool, - ({ block, vertical }) => - block && !vertical - ? new Error('`block` requires `vertical` to be set to have any effect') - : null - ) + block: PropTypes.bool }; const defaultProps = { @@ -39,6 +33,11 @@ class ButtonGroup extends React.Component { const { block, justified, vertical, className, ...props } = this.props; const [bsProps, elementProps] = splitBsProps(props); + warning( + !(block && !vertical), + '`block` requires `vertical` to be set to have any effect' + ); + const classes = { ...getClassSet(bsProps), [prefix(bsProps)]: !vertical, diff --git a/src/Dropdown.js b/src/Dropdown.js index f5db1aaae2..7bf64ad97d 100644 --- a/src/Dropdown.js +++ b/src/Dropdown.js @@ -3,17 +3,17 @@ import activeElement from 'dom-helpers/activeElement'; import contains from 'dom-helpers/query/contains'; import React, { cloneElement } from 'react'; import PropTypes from 'prop-types'; -import all from 'prop-types-extra/lib/all'; import elementType from 'prop-types-extra/lib/elementType'; import isRequiredForA11y from 'prop-types-extra/lib/isRequiredForA11y'; import uncontrollable from 'uncontrollable'; +import warning from 'warning'; import ButtonGroup from './ButtonGroup'; import DropdownMenu from './DropdownMenu'; import DropdownToggle from './DropdownToggle'; import { bsClass as setBsClass, prefix } from './utils/bootstrapUtils'; import createChainedFunction from './utils/createChainedFunction'; -import { exclusiveRoles, requiredRoles } from './utils/PropTypes'; +import { getDuplicateRoleError, getMissingRoleError } from './utils/PropTypes'; import ValidComponentChildren from './utils/ValidComponentChildren'; import { getElementRef, makeMergedRef } from './utils/mergeRefs'; @@ -41,10 +41,7 @@ const propTypes = { * The children of a Dropdown may be a `` or a ``. * @type {node} */ - children: all( - requiredRoles(TOGGLE_ROLE, MENU_ROLE), - exclusiveRoles(MENU_ROLE) - ), + children: PropTypes.node, /** * Whether or not component is disabled. @@ -293,6 +290,25 @@ class Dropdown extends React.Component { delete props.onToggle; + const missingRoleError = getMissingRoleError( + 'Dropdown', + children, + TOGGLE_ROLE, + MENU_ROLE + ); + if (missingRoleError) { + warning(false, missingRoleError); + } + + const duplicateRoleError = getDuplicateRoleError( + 'Dropdown', + children, + MENU_ROLE + ); + if (duplicateRoleError) { + warning(false, duplicateRoleError); + } + const classes = { [bsClass]: true, open, diff --git a/src/MenuItem.js b/src/MenuItem.js index 9805bda8ae..2e24b82e3d 100644 --- a/src/MenuItem.js +++ b/src/MenuItem.js @@ -1,7 +1,7 @@ import classNames from 'classnames'; import React from 'react'; import PropTypes from 'prop-types'; -import all from 'prop-types-extra/lib/all'; +import warning from 'warning'; import SafeAnchor from './SafeAnchor'; import { bsClass, prefix, splitBsPropsAndOmit } from './utils/bootstrapUtils'; @@ -22,13 +22,7 @@ const propTypes = { * Styles the menu item as a horizontal rule, providing visual separation between * groups of menu items. */ - divider: all( - PropTypes.bool, - ({ divider, children }) => - divider && children - ? new Error('Children will not be rendered for dividers') - : null - ), + divider: PropTypes.bool, /** * Value passed to the `onSelect` handler, useful for identifying the selected menu item. @@ -101,6 +95,11 @@ class MenuItem extends React.Component { ...props } = this.props; + warning( + !(divider && props.children), + 'Children will not be rendered for dividers' + ); + const [bsProps, elementProps] = splitBsPropsAndOmit(props, [ 'eventKey', 'onSelect' diff --git a/src/Nav.js b/src/Nav.js index 439a4172c6..629db9e824 100644 --- a/src/Nav.js +++ b/src/Nav.js @@ -1,7 +1,6 @@ import classNames from 'classnames'; import React, { cloneElement, useContext } from 'react'; import PropTypes from 'prop-types'; -import all from 'prop-types-extra/lib/all'; import warning from 'warning'; import NavbarContext from './NavbarContext'; @@ -40,13 +39,7 @@ const propTypes = { */ stacked: PropTypes.bool, - justified: all( - PropTypes.bool, - ({ justified, navbar }) => - justified && navbar - ? Error('justified navbar `Nav`s are not supported') - : null - ), + justified: PropTypes.bool, /** * A callback fired when a NavItem is selected. @@ -287,6 +280,11 @@ class Nav extends React.Component { const [bsProps, elementProps] = splitBsProps(props); + warning( + !(justified && propsNavbar), + 'justified navbar `Nav`s are not supported' + ); + const classes = { ...getClassSet(bsProps), [prefix(bsProps, 'stacked')]: stacked, diff --git a/src/ProgressBar.js b/src/ProgressBar.js index 63aa306873..0e90dc30ed 100644 --- a/src/ProgressBar.js +++ b/src/ProgressBar.js @@ -1,6 +1,7 @@ import classNames from 'classnames'; import React, { cloneElement } from 'react'; import PropTypes from 'prop-types'; +import warning from 'warning'; import { bsClass as setBsClass, @@ -17,12 +18,7 @@ const ROUND_PRECISION = 1000; /** * Validate that children, if any, are instances of ``. */ -function onlyProgressBar(props, propName, componentName) { - const children = props[propName]; - if (!children) { - return null; - } - +function getInvalidChildError(children) { let error = null; React.Children.forEach(children, child => { @@ -42,10 +38,9 @@ function onlyProgressBar(props, propName, componentName) { const childIdentifier = React.isValidElement(child) ? child.type.displayName || child.type.name || child.type : child; - error = new Error( - `Children of ${componentName} can contain only ProgressBar ` + - `components. Found ${childIdentifier}.` - ); + error = + `Children of ProgressBar can contain only ProgressBar ` + + `components. Found ${childIdentifier}.`; }); return error; @@ -59,7 +54,7 @@ const propTypes = { srOnly: PropTypes.bool, striped: PropTypes.bool, active: PropTypes.bool, - children: onlyProgressBar, + children: PropTypes.node, /** * @private @@ -139,6 +134,11 @@ class ProgressBar extends React.Component { ...wrapperProps } = props; + const childError = children ? getInvalidChildError(children) : null; + if (childError) { + warning(false, childError); + } + return (
{children diff --git a/src/utils/PropTypes.js b/src/utils/PropTypes.js index 0eb25d4bb5..ad7769f560 100644 --- a/src/utils/PropTypes.js +++ b/src/utils/PropTypes.js @@ -1,5 +1,4 @@ import PropTypes from 'prop-types'; -import createChainableTypeChecker from 'prop-types-extra/lib/utils/createChainableTypeChecker'; import ValidComponentChildren from './ValidComponentChildren'; @@ -23,62 +22,67 @@ export function generatedId(name) { }; } -export function requiredRoles(...roles) { - return createChainableTypeChecker((props, propName, component) => { - let missing; - - roles.every(role => { - if ( - !ValidComponentChildren.some( - props.children, - child => child.props.bsRole === role - ) - ) { - missing = role; - return false; - } - - return true; - }); - - if (missing) { - return new Error( - `(children) ${component} - Missing a required child with bsRole: ` + - `${missing}. ${component} must have at least one child of each of ` + - `the following bsRoles: ${roles.join(', ')}` - ); +/** + * Return a warning message if `children` is missing a child for any of the + * required `roles`, otherwise `null`. `bsRole` is matched against each child's + * `bsRole` prop. + */ +export function getMissingRoleError(component, children, ...roles) { + let missing; + + roles.every(role => { + if ( + !ValidComponentChildren.some( + children, + child => child.props.bsRole === role + ) + ) { + missing = role; + return false; } - return null; + return true; }); -} - -export function exclusiveRoles(...roles) { - return createChainableTypeChecker((props, propName, component) => { - let duplicate; - roles.every(role => { - const childrenWithRole = ValidComponentChildren.filter( - props.children, - child => child.props.bsRole === role - ); - - if (childrenWithRole.length > 1) { - duplicate = role; - return false; - } + if (missing) { + return ( + `(children) ${component} - Missing a required child with bsRole: ` + + `${missing}. ${component} must have at least one child of each of ` + + `the following bsRoles: ${roles.join(', ')}` + ); + } - return true; - }); + return null; +} - if (duplicate) { - return new Error( - `(children) ${component} - Duplicate children detected of bsRole: ` + - `${duplicate}. Only one child each allowed with the following ` + - `bsRoles: ${roles.join(', ')}` - ); +/** + * Return a warning message if `children` contains more than one child for any + * of the exclusive `roles`, otherwise `null`. + */ +export function getDuplicateRoleError(component, children, ...roles) { + let duplicate; + + roles.every(role => { + const childrenWithRole = ValidComponentChildren.filter( + children, + child => child.props.bsRole === role + ); + + if (childrenWithRole.length > 1) { + duplicate = role; + return false; } - return null; + return true; }); + + if (duplicate) { + return ( + `(children) ${component} - Duplicate children detected of bsRole: ` + + `${duplicate}. Only one child each allowed with the following ` + + `bsRoles: ${roles.join(', ')}` + ); + } + + return null; } diff --git a/src/utils/bootstrapUtils.js b/src/utils/bootstrapUtils.js index da59292407..53a7e4bfe9 100644 --- a/src/utils/bootstrapUtils.js +++ b/src/utils/bootstrapUtils.js @@ -3,9 +3,13 @@ import invariant from 'invariant'; import { isValidElementType } from 'react-is'; import PropTypes from 'prop-types'; +import React from 'react'; +import warning from 'warning'; import { SIZE_MAP } from './StyleConfig'; +const DEV = process.env.NODE_ENV !== 'production'; + function curry(fn) { return (...args) => { let last = args[args.length - 1]; @@ -19,6 +23,131 @@ function curry(fn) { }; } +function componentName(Component) { + return Component.displayName || Component.name || 'Component'; +} + +function getComponentType(Component) { + if ( + Component && + Component.prototype && + typeof Component.prototype.render === 'function' + ) { + return 'class'; + } else if (typeof Component === 'function') { + return 'function'; + } + + // Component is likely a forwardRef component + return 'other'; +} + +function warnOutOfRange(name, propName, value, allowed) { + if (value != null && allowed.indexOf(value) === -1) { + warning( + value == null || allowed.indexOf(value) !== -1, + `Invalid prop \`${propName}\` of value \`${value}\` supplied to ` + + `\`${name}\`, expected one of ${JSON.stringify(allowed)}.` + ); + } +} + +/** + * Patches a class component's `render` method to validate the given prop value, + * mirroring how prop-types validation works before React 19. + */ +function patchRenderValidation(Component, { propName, allowed }) { + if (allowed) { + const name = componentName(Component); + const innerRender = Component.prototype.render; + + Component.prototype.render = function validatedRender(...renderArgs) { + warnOutOfRange(name, propName, this.props[propName], allowed); + return innerRender.apply(this, renderArgs); + }; + } + + return Component; +} + +/** + * Adds a default prop value to a non-function component. + */ +function addDefaultProp(Component, { propName, defaultValue }) { + if (defaultValue !== undefined) { + Component.defaultProps = { + ...Component.defaultProps, + [propName]: defaultValue + }; + } + + return Component; +} + +/** + * Adds a default prop value to a function component and adds development-only + * warning messages when allowed is provided. + */ +function wrapFunctionComponent(Component, { propName, defaultValue, allowed }) { + if (defaultValue === undefined && !allowed) { + return Component; + } + + const name = componentName(Component); + + function WrappedComponent(props) { + const resolved = + defaultValue !== undefined && props[propName] === undefined + ? { ...props, [propName]: defaultValue } + : props; + + if (allowed) { + warnOutOfRange(name, propName, resolved[propName], allowed); + } + + return React.createElement(Component, resolved); + } + + WrappedComponent.displayName = name; + + // Carry over metadata read elsewhere: `propTypes`/`_values` for docs and + // `STYLES`/`SIZES` for decorator chaining. + if (Component.propTypes) WrappedComponent.propTypes = Component.propTypes; + if (Component.STYLES) WrappedComponent.STYLES = Component.STYLES; + if (Component.SIZES) WrappedComponent.SIZES = Component.SIZES; + + return WrappedComponent; +} + +/** + * Applies a default prop value and optional, development-only validation + * messages to the given Component. + */ +function applyBsProp(Component, options) { + const componentType = getComponentType(Component); + + if (!DEV && options.allowed) { + // Only validate this prop during development + options.allowed = undefined; + } + + switch (componentType) { + case 'class': + Component = addDefaultProp(Component, options); + Component = patchRenderValidation(Component, options); + break; + case 'function': + Component = wrapFunctionComponent(Component, options); + break; + default: + // No prop validation is added for forwardRef components + Component = addDefaultProp(Component, options); + break; + } + + return Component; +} + export function prefix(props, variant) { let bsClass = (props.bsClass || '').trim(); invariant(bsClass != null, 'A `bsClass` prop is required for this component'); @@ -27,12 +156,13 @@ export function prefix(props, variant) { export const bsClass = curry((defaultClass, Component) => { let propTypes = Component.propTypes || (Component.propTypes = {}); - let defaultProps = Component.defaultProps || (Component.defaultProps = {}); propTypes.bsClass = PropTypes.string; - defaultProps.bsClass = defaultClass; - return Component; + return applyBsProp(Component, { + propName: 'bsClass', + defaultValue: defaultClass + }); }); export const bsStyles = curry((styles, defaultStyle, Component) => { @@ -61,12 +191,11 @@ export const bsStyles = curry((styles, defaultStyle, Component) => { bsStyle: propType }; - if (defaultStyle !== undefined) { - let defaultProps = Component.defaultProps || (Component.defaultProps = {}); - defaultProps.bsStyle = defaultStyle; - } - - return Component; + return applyBsProp(Component, { + propName: 'bsStyle', + defaultValue: defaultStyle, + allowed: existing + }); }); export const bsSizes = curry((sizes, defaultSize, Component) => { @@ -105,14 +234,11 @@ export const bsSizes = curry((sizes, defaultSize, Component) => { bsSize: propType }; - if (defaultSize !== undefined) { - if (!Component.defaultProps) { - Component.defaultProps = {}; - } - Component.defaultProps.bsSize = defaultSize; - } - - return Component; + return applyBsProp(Component, { + propName: 'bsSize', + defaultValue: defaultSize, + allowed: values + }); }); export function getClassSet(props) { diff --git a/test/DropdownSpec.js b/test/DropdownSpec.js index cc05ffaa74..614404da4c 100644 --- a/test/DropdownSpec.js +++ b/test/DropdownSpec.js @@ -5,6 +5,7 @@ import React from 'react'; import Dropdown from '../src/Dropdown'; import Grid from '../src/Grid'; import MenuItem from '../src/MenuItem'; +import { getDuplicateRoleError } from '../src/utils/PropTypes'; import { shouldWarn } from './helpers'; @@ -15,8 +16,6 @@ class CustomMenu extends React.Component { } describe('', () => { - let BaseDropdown = Dropdown.ControlledComponent; - const dropdownChildren = [ Child Title, @@ -110,12 +109,8 @@ describe('', () => { ] }; - let err = BaseDropdown.propTypes.children( - props, - 'children', - 'DropdownButton' - ); - err.message.should.match(/Duplicate children.*bsRole: menu/); + const err = getDuplicateRoleError('DropdownButton', props.children, 'menu'); + err.should.match(/Duplicate children.*bsRole: menu/); }); it('forwards pullRight to menu', () => { diff --git a/test/ProgressBarSpec.js b/test/ProgressBarSpec.js index db4ae2a723..eb932bf03b 100644 --- a/test/ProgressBarSpec.js +++ b/test/ProgressBarSpec.js @@ -217,7 +217,7 @@ describe('', () => { }); it('allows only ProgressBar in children', () => { - shouldWarn('Failed prop'); + shouldWarn('can contain only ProgressBar'); function NotProgressBar() { return null; diff --git a/test/index.js b/test/index.js index 3b8d18f81c..c05f3a71e1 100644 --- a/test/index.js +++ b/test/index.js @@ -21,11 +21,6 @@ beforeEach(() => { return; } - if (msg.includes('defaultProps')) { - // @hmhealey This is removed in React 19 - return; - } - console.error.threw = true; throw new Error(msg); }); diff --git a/test/utils/bootstrapUtilsSpec.js b/test/utils/bootstrapUtilsSpec.js index 466e079996..cfc5dc1e7a 100644 --- a/test/utils/bootstrapUtilsSpec.js +++ b/test/utils/bootstrapUtilsSpec.js @@ -79,15 +79,14 @@ describe('bootstrapUtils', () => { describe('bsStyles', () => { it('should add style to allowed propTypes', () => { - const Component = () => null; - bsStyles(['minimal', 'boss', 'plaid'])(Component); + const Component = bsStyles(['minimal', 'boss', 'plaid'])(() => null); expect(Component.propTypes).to.exist; - React.createElement(Component, { bsStyle: 'plaid' }); + render(React.createElement(Component, { bsStyle: 'plaid' })); shouldWarn('expected one of ["minimal","boss","plaid"]'); - React.createElement(Component, { bsStyle: 'not-plaid' }); + render(React.createElement(Component, { bsStyle: 'not-plaid' })); }); it('should not override other propTypes', () => { @@ -101,13 +100,14 @@ describe('bootstrapUtils', () => { }); it('should set a default if provided', () => { - const propTypes = { other: PropTypes.string }; - const Component = () => null; - Component.propTypes = propTypes; - bsStyles(['minimal', 'boss', 'plaid'], 'plaid')(Component); + const Inner = props => ; + const Component = bsStyles(['minimal', 'boss', 'plaid'], 'plaid')(Inner); - expect(Component.defaultProps).to.exist; - expect(Component.defaultProps.bsStyle).to.equal('plaid'); + const { container } = render(React.createElement(Component)); + + expect( + container.querySelector('span').getAttribute('data-bs-style') + ).to.equal('plaid'); }); it('should work with ES classes', () => { @@ -178,16 +178,15 @@ describe('bootstrapUtils', () => { describe('bsSizes', () => { it('should add size to allowed propTypes', () => { - const Component = () => null; - bsSizes(['large', 'small'])(Component); + const Component = bsSizes(['large', 'small'])(() => null); expect(Component.propTypes).to.exist; - React.createElement(Component, { bsSize: 'small' }); - React.createElement(Component, { bsSize: 'sm' }); + render(React.createElement(Component, { bsSize: 'small' })); + render(React.createElement(Component, { bsSize: 'sm' })); shouldWarn('expected one of ["lg","large","sm","small"]'); - React.createElement(Component, { bsSize: 'superSmall' }); + render(React.createElement(Component, { bsSize: 'superSmall' })); }); it('should not override other propTypes', () => {