diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..3751ba8 --- /dev/null +++ b/.babelrc @@ -0,0 +1,19 @@ +{ + "plugins": [ + "add-module-exports", + "lodash", + "transform-class-properties", + [ + "transform-es2015-classes", + { + "loose": true + } + ], + "transform-proto-to-assign" + ], + "presets": [ + "es2015", + "stage-0", + "react" + ] +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..0f17867 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 2 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..bcaad1b --- /dev/null +++ b/.eslintrc @@ -0,0 +1,6 @@ +{ + "extends": [ + "canonical", + "canonical/mocha" + ] +} diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..2f093a7 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,2 @@ +github: gajus +patreon: gajus diff --git a/.gitignore b/.gitignore index cf9c7f9..df978de 100755 --- a/.gitignore +++ b/.gitignore @@ -3,8 +3,10 @@ coverage dist *.log .* -!.README +!.babelrc +!.editorconfig +!.eslintrc !.gitignore !.npmignore -!.babelrc +!.README !.travis.yml diff --git a/.travis.yml b/.travis.yml index e07d95b..a786167 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,7 +1,14 @@ language: node_js node_js: - - 5 - - 4 + - node + - 8 +before_install: + - npm config set depth 0 notifications: - email: false + email: false sudo: false +script: + - npm run test + - npm run lint +after_success: + - semantic-release pre && npm publish && semantic-release post diff --git a/ISSUE_TEMPLATE.md b/ISSUE_TEMPLATE.md new file mode 100644 index 0000000..109eb2e --- /dev/null +++ b/ISSUE_TEMPLATE.md @@ -0,0 +1,5 @@ + diff --git a/README.md b/README.md index 5c8c0ef..a6b7399 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ # React CSS Modules +[![GitSpo Mentions](https://gitspo.com/badges/mentions/gajus/react-css-modules?style=flat-square)](https://gitspo.com/mentions/gajus/react-css-modules) [![Travis build status](http://img.shields.io/travis/gajus/react-css-modules/master.svg?style=flat-square)](https://travis-ci.org/gajus/react-css-modules) [![NPM version](http://img.shields.io/npm/v/react-css-modules.svg?style=flat-square)](https://www.npmjs.org/package/react-css-modules) [![js-canonical-style](https://img.shields.io/badge/code%20style-canonical-blue.svg?style=flat-square)](https://github.com/gajus/canonical) @@ -8,6 +9,16 @@ React CSS Modules implement automatic mapping of CSS modules. Every CSS class is assigned a local-scoped identifier with a global unique name. CSS Modules enable a modular and reusable CSS! +> ## ⚠️⚠️⚠️ DEPRECATION NOTICE ⚠️⚠️⚠️ +> +> If you are considering to use `react-css-modules`, evaluate if [`babel-plugin-react-css-modules`](https://github.com/gajus/babel-plugin-react-css-modules) covers your use case. +> `babel-plugin-react-css-modules` is a lightweight alternative of `react-css-modules`. +> +> `babel-plugin-react-css-modules` is not a drop-in replacement and does not cover all the use cases of `react-css-modules`. +> However, it has a lot smaller performance overhead (0-10% vs +50%; see [Performance](https://github.com/gajus/babel-plugin-react-css-modules#performance)) and a lot smaller size footprint (less than 2kb vs +17kb). +> +> It is easy to get started! See the demo https://github.com/gajus/babel-plugin-react-css-modules/tree/master/demo + - [CSS Modules](#css-modules) - [webpack `css-loader`](#webpack-css-loader) - [What's the Problem?](#whats-the-problem) @@ -24,19 +35,18 @@ React CSS Modules implement automatic mapping of CSS modules. Every CSS class is - [Decorator](#decorator) - [Options](#options) - [`allowMultiple`](#allowmultiple) - - [`errorWhenNotFound`](#errorwhennotfound) + - [`handleNotFoundStyleName`](#handlenotfoundstylename) - [SASS, SCSS, LESS and other CSS Preprocessors](#sass-scss-less-and-other-css-preprocessors) - [Enable Sourcemaps](#enable-sourcemaps) - [Class Composition](#class-composition) - [What Problems does Class Composition Solve?](#what-problems-does-class-composition-solve) - [Class Composition Using CSS Preprocessors](#class-composition-using-css-preprocessors) -- [SASS, SCSS, LESS and other CSS Preprocessors](#sass-scss-less-and-other-css-preprocessors) - [Global CSS](#global-css) - [Multiple CSS Modules](#multiple-css-modules) ## CSS Modules -[CSS Modules](https://github.com/css-modules/css-modules) are awesome. If you are not familiar with CSS Modules, it is a concept of using a module bundler such as [webpack](http://webpack.github.io/docs/) to load CSS scoped to a particular document. CSS module loader will generate a unique name for a each CSS class at the time of loading the CSS document ([Interoperable CSS](https://github.com/css-modules/icss) to be precise). To see CSS Modules in practice, [webpack-demo](https://css-modules.github.io/webpack-demo/). +[CSS Modules](https://github.com/css-modules/css-modules) are awesome. If you are not familiar with CSS Modules, it is a concept of using a module bundler such as [webpack](http://webpack.github.io/docs/) to load CSS scoped to a particular document. CSS module loader will generate a unique name for each CSS class at the time of loading the CSS document ([Interoperable CSS](https://github.com/css-modules/icss) to be precise). To see CSS Modules in practice, [webpack-demo](https://css-modules.github.io/webpack-demo/). In the context of React, CSS Modules look like this: @@ -61,7 +71,7 @@ Rendering the component will produce a markup similar to: ```js
-
A0
+
A0
B0
@@ -73,7 +83,7 @@ Awesome! ### webpack `css-loader` -[CSS Modules](https://github.com/css-modules/css-modules) is a specification that can be implemented in multiple ways. `react-css-modules` leverages as existing CSS Modules implementation webpack [css-loader](https://github.com/webpack/css-loader#css-modules). +[CSS Modules](https://github.com/css-modules/css-modules) is a specification that can be implemented in multiple ways. `react-css-modules` leverages the existing CSS Modules implementation webpack [css-loader](https://github.com/webpack/css-loader#css-modules). ## What's the Problem? @@ -115,7 +125,7 @@ Using `react-css-modules`:
``` -* You are warned when `styleName` refers to an undefined CSS Module ([`errorWhenNotFound`](#errorwhennotfound) option). +* You are warned when `styleName` refers to an undefined CSS Module ([`handleNotFoundStyleName`](#handlenotfoundstylename) option). * You can enforce use of a single CSS module per `ReactElement` ([`allowMultiple`](#allowmultiple) option). ## The Implementation @@ -149,8 +159,8 @@ Setup: { test: /\.css$/, loaders: [ - 'style?sourceMap', - 'css?modules&importLoaders=1&localIdentName=[path]___[name]__[local]___[hash:base64:5]' + 'style-loader?sourceMap', + 'css-loader?modules&importLoaders=1&localIdentName=[path]___[name]__[local]___[hash:base64:5]' ] } ``` @@ -182,22 +192,48 @@ Setup: * Install [`style-loader`](https://www.npmjs.com/package/style-loader). * Install [`css-loader`](https://www.npmjs.com/package/css-loader). * Use [`extract-text-webpack-plugin`](https://www.npmjs.com/package/extract-text-webpack-plugin) to extract chunks of CSS into a single stylesheet. + * Setup `/\.css$/` loader: -```js -{ - test: /\.css$/, - loader: ExtractTextPlugin.extract('style', 'css?modules&importLoaders=1&localIdentName=[name]__[local]___[hash:base64:5]') -} -``` + * ExtractTextPlugin v1x: + + ```js + { + test: /\.css$/, + loader: ExtractTextPlugin.extract('style', 'css?modules&importLoaders=1&localIdentName=[name]__[local]___[hash:base64:5]') + } + ``` + + * ExtractTextPlugin v2x: + + ```js + { + test: /\.css$/, + use: ExtractTextPlugin.extract({ + fallback: 'style-loader', + use: 'css-loader?modules,localIdentName="[name]-[local]-[hash:base64:6]"' + }), + } + ``` * Setup `extract-text-webpack-plugin` plugin: -```js -new ExtractTextPlugin('app.css', { - allChunks: true -}) -``` + * ExtractTextPlugin v1x: + + ```js + new ExtractTextPlugin('app.css', { + allChunks: true + }) + ``` + + * ExtractTextPlugin v2x: + + ```js + new ExtractTextPlugin({ + filename: 'app.css', + allChunks: true + }) + ``` Refer to [webpack-demo](https://github.com/css-modules/webpack-demo) or [react-css-modules-examples](https://github.com/gajus/react-css-modules-examples) for an example of a complete setup. @@ -370,7 +406,7 @@ export default CSSModules(CustomList, styles); * @typedef CSSModules~Options * @see {@link https://github.com/gajus/react-css-modules#options} * @property {Boolean} allowMultiple - * @property {Boolean} errorWhenNotFound + * @property {String} handleNotFoundStyleName */ /** @@ -454,11 +490,17 @@ When `false`, the following will cause an error:
``` -#### `errorWhenNotFound` +#### `handleNotFoundStyleName` + +Default: `throw`. + +Defines the desired action when `styleName` cannot be mapped to an existing CSS Module. -Default: `true`. +Available options: -Throws an error when `styleName` cannot be mapped to an existing CSS Module. +* `throw` throws an error +* `log` logs a warning using `console.warn` +* `ignore` silently ignores the missing style name ## SASS, SCSS, LESS and other CSS Preprocessors @@ -548,7 +590,7 @@ This pattern emerged with the advent of OOCSS. The biggest disadvantage of this ### Class Composition Using CSS Preprocessors -This section of the document is included as a learning exercise to broaden the understanding about the origin of Class Composition. CSS Modules support a native method of composting CSS Modules using [`composes`](https://github.com/css-modules/css-modules#composition) keyword. CSS Preprocessor is not required. +This section of the document is included as a learning exercise to broaden the understanding about the origin of Class Composition. CSS Modules support a native method of composing CSS Modules using [`composes`](https://github.com/css-modules/css-modules#composition) keyword. CSS Preprocessor is not required. You can write compositions in SCSS using [`@extend`](http://sass-lang.com/documentation/file.SASS_REFERENCE.html#extend) keyword and using [Mixin Directives](http://sass-lang.com/documentation/file.SASS_REFERENCE.html#mixins), e.g. diff --git a/package.json b/package.json index c8e6a8e..00e1ffa 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "react-css-modules", "description": "Seamless mapping of class names to CSS modules inside of React components.", - "main": "./dist/", + "main": "./dist/index.js", "repository": { "type": "git", "url": "https://github.com/gajus/react-css-modules" @@ -12,7 +12,7 @@ "css", "modules" ], - "version": "3.7.5", + "version": "4.3.0", "author": { "name": "Gajus Kuizinas", "email": "gajus@gajus.com", @@ -20,27 +20,36 @@ }, "license": "BSD-3-Clause", "dependencies": { - "es6-map": "^0.1.3", - "hoist-non-react-statics": "^1.0.5", - "lodash": "^4.5.1", - "object-unfreeze": "^1.0.2" + "hoist-non-react-statics": "^2.5.5", + "lodash": "^4.16.6", + "object-unfreeze": "^1.1.0" }, "devDependencies": { - "chai": "^3.5.0", - "jsdom": "^8.0.4", - "pragmatist": "^3.0.16", - "react": "^0.15.0-alpha.1", - "react-addons-test-utils": "^0.15.0-alpha.1", - "react-dom": "^0.15.0-alpha.1" + "babel-cli": "^6.18.0", + "babel-plugin-add-module-exports": "^0.2.1", + "babel-plugin-lodash": "^3.2.9", + "babel-plugin-transform-proto-to-assign": "^6.9.0", + "babel-preset-es2015": "^6.18.0", + "babel-preset-react": "^6.16.0", + "babel-preset-stage-0": "^6.16.0", + "babel-register": "^6.18.0", + "chai": "^4.0.0-canary.1", + "chai-spies": "^0.7.1", + "eslint": "^3.10.0", + "eslint-config-canonical": "^5.5.0", + "husky": "^0.11.9", + "jsdom": "^9.8.3", + "mocha": "^3.1.2", + "react": "^15.4.0-rc.4", + "react-addons-shallow-compare": "^15.4.0-rc.4", + "react-addons-test-utils": "^15.4.0-rc.4", + "react-dom": "^15.4.0-rc.4", + "semantic-release": "^6.3.2" }, "scripts": { - "pragmatist": "node ./node_modules/.bin/pragmatist --es5", - "lint": "npm run pragmatist lint", - "test": "npm run pragmatist test --type-annotations", - "build": "npm run pragmatist build", - "watch": "npm run pragmatist watch", - "watch-lint": "npm run pragmatist watch-lint", - "watch-test": "npm run pragmatist watch-test --type-annotations", - "watch-build": "npm run pragmatist watch-build" + "lint": "eslint ./src ./tests", + "test": "NODE_ENV=development mocha --compilers js:babel-register ./tests/**/*.js && npm run lint && npm run build", + "build": "NODE_ENV=production babel ./src --out-dir ./dist", + "precommit": "npm run test" } } diff --git a/src/SimpleMap.js b/src/SimpleMap.js new file mode 100644 index 0000000..7cf618b --- /dev/null +++ b/src/SimpleMap.js @@ -0,0 +1,21 @@ +export default class { + constructor () { + this.size = 0; + this.keys = []; + this.values = []; + } + + get (key) { + const index = this.keys.indexOf(key); + + return this.values[index]; + } + + set (key, value) { + this.keys.push(key); + this.values.push(value); + this.size = this.keys.length; + + return value; + } +} diff --git a/src/extendReactClass.js b/src/extendReactClass.js index 45b36a7..4741bea 100644 --- a/src/extendReactClass.js +++ b/src/extendReactClass.js @@ -1,9 +1,10 @@ /* eslint-disable react/prop-types */ -import linkClass from './linkClass'; -import React from 'react'; import _ from 'lodash'; +import React from 'react'; import hoistNonReactStatics from 'hoist-non-react-statics'; +import linkClass from './linkClass'; +import renderNothing from './renderNothing'; /** * @param {ReactClass} Component @@ -12,31 +13,62 @@ import hoistNonReactStatics from 'hoist-non-react-statics'; * @returns {ReactClass} */ export default (Component: Object, defaultStyles: Object, options: Object) => { - const WrappedComponent = class extends Component { - render () { - let styles; + const WrappedComponent = class extends Component { + render () { + let styles; - if (this.props.styles) { - styles = this.props.styles; - } else if (_.isObject(defaultStyles)) { - this.props = _.assign({}, this.props, { - styles: defaultStyles - }); + const hasDefaultstyles = _.isObject(defaultStyles); + + let renderResult; + + if (this.props.styles || hasDefaultstyles) { + const props = Object.assign({}, this.props); + + if (props.styles) { + styles = props.styles; + } else if (hasDefaultstyles) { + styles = defaultStyles; + delete props.styles; + } - styles = defaultStyles; - } else { - styles = {}; - } + Object.defineProperty(props, 'styles', { + configurable: true, + enumerable: false, + value: styles, + writable: false + }); - const renderResult = super.render(); + const originalProps = this.props; - if (renderResult) { - return linkClass(renderResult, styles, options); - } + let renderIsSuccessful = false; - return React.createElement('noscript'); + try { + this.props = props; + + renderResult = super.render(); + + renderIsSuccessful = true; + } finally { + this.props = originalProps; + } + + // @see https://github.com/facebook/react/issues/14224 + if (!renderIsSuccessful) { + renderResult = super.render(); } - }; + } else { + styles = {}; + + renderResult = super.render(); + } + + if (renderResult) { + return linkClass(renderResult, styles, options); + } + + return renderNothing(React.version); + } + }; - return hoistNonReactStatics(WrappedComponent, Component); + return hoistNonReactStatics(WrappedComponent, Component); }; diff --git a/src/generateAppendClassName.js b/src/generateAppendClassName.js index b4e6bd8..127aa2a 100644 --- a/src/generateAppendClassName.js +++ b/src/generateAppendClassName.js @@ -1,40 +1,49 @@ -import Map from 'es6-map'; +import SimpleMap from './SimpleMap'; -const stylesIndex = new Map(); +const CustomMap = typeof Map === 'undefined' ? SimpleMap : Map; -export default (styles, styleNames: Array, errorWhenNotFound: boolean): string => { - let appendClassName, - stylesIndexMap; +const stylesIndex = new CustomMap(); - stylesIndexMap = stylesIndex.get(styles); +export default (styles, styleNames: Array, handleNotFoundStyleName: "throw" | "log" | "ignore"): string => { + let appendClassName; + let stylesIndexMap; - if (stylesIndexMap) { - const styleNameIndex = stylesIndexMap.get(styleNames); + stylesIndexMap = stylesIndex.get(styles); - if (styleNameIndex) { - return styleNameIndex; - } - } else { - stylesIndexMap = stylesIndex.set(styles, new Map()); - } - - appendClassName = ''; + if (stylesIndexMap) { + const styleNameIndex = stylesIndexMap.get(styleNames); - for (const styleName in styleNames) { - if (styleNames.hasOwnProperty(styleName)) { - const className = styles[styleNames[styleName]]; - - if (className) { - appendClassName += ' ' + className; - } else if (errorWhenNotFound === true) { - throw new Error('"' + styleNames[styleName] + '" CSS module is undefined.'); - } + if (styleNameIndex) { + return styleNameIndex; + } + } else { + stylesIndexMap = new CustomMap(); + stylesIndex.set(styles, new CustomMap()); + } + + appendClassName = ''; + + for (const styleName in styleNames) { + if (styleNames.hasOwnProperty(styleName)) { + const className = styles[styleNames[styleName]]; + + if (className) { + appendClassName += ' ' + className; + } else { + if (handleNotFoundStyleName === 'throw') { + throw new Error('"' + styleNames[styleName] + '" CSS module is undefined.'); + } + if (handleNotFoundStyleName === 'log') { + // eslint-disable-next-line no-console + console.warn('"' + styleNames[styleName] + '" CSS module is undefined.'); } + } } + } - appendClassName = appendClassName.trim(); + appendClassName = appendClassName.trim(); - stylesIndexMap.set(styleNames, appendClassName); + stylesIndexMap.set(styleNames, appendClassName); - return appendClassName; + return appendClassName; }; diff --git a/src/index.js b/src/index.js index 6c1a78c..b913b29 100644 --- a/src/index.js +++ b/src/index.js @@ -1,53 +1,56 @@ import _ from 'lodash'; import extendReactClass from './extendReactClass'; import wrapStatelessFunction from './wrapStatelessFunction'; +import makeConfiguration from './makeConfiguration'; /** * @see https://github.com/gajus/react-css-modules#options */ -type OptionsType = {}; +type TypeOptions = {}; /** * Determines if the given object has the signature of a class that inherits React.Component. */ const isReactComponent = (maybeReactComponent: any): boolean => { - return 'prototype' in maybeReactComponent && _.isFunction(maybeReactComponent.prototype.render); + return 'prototype' in maybeReactComponent && _.isFunction(maybeReactComponent.prototype.render); }; /** * When used as a function. */ -const functionConstructor = (Component: Function, defaultStyles: Object, options: OptionsType): Function => { - let decoratedClass; - - if (isReactComponent(Component)) { - decoratedClass = extendReactClass(Component, defaultStyles, options); - } else { - decoratedClass = wrapStatelessFunction(Component, defaultStyles, options); - } - - if (Component.displayName) { - decoratedClass.displayName = Component.displayName; - } else { - decoratedClass.displayName = Component.name; - } - - return decoratedClass; +const functionConstructor = (Component: Function, defaultStyles: Object, options: TypeOptions): Function => { + let decoratedClass; + + const configuration = makeConfiguration(options); + + if (isReactComponent(Component)) { + decoratedClass = extendReactClass(Component, defaultStyles, configuration); + } else { + decoratedClass = wrapStatelessFunction(Component, defaultStyles, configuration); + } + + if (Component.displayName) { + decoratedClass.displayName = Component.displayName; + } else { + decoratedClass.displayName = Component.name; + } + + return decoratedClass; }; /** * When used as a ES7 decorator. */ -const decoratorConstructor = (defaultStyles: Object, options: OptionsType): Function => { - return (Component: Function) => { - return functionConstructor(Component, defaultStyles, options); - }; +const decoratorConstructor = (defaultStyles: Object, options: TypeOptions): Function => { + return (Component: Function) => { + return functionConstructor(Component, defaultStyles, options); + }; }; export default (...args) => { - if (_.isFunction(args[0])) { - return functionConstructor(args[0], args[1], args[2]); - } else { - return decoratorConstructor(args[0], args[1]); - } + if (_.isFunction(args[0])) { + return functionConstructor(args[0], args[1], args[2]); + } else { + return decoratorConstructor(args[0], args[1]); + } }; diff --git a/src/isIterable.js b/src/isIterable.js index ac6dc72..67d0a94 100644 --- a/src/isIterable.js +++ b/src/isIterable.js @@ -1,6 +1,6 @@ import _ from 'lodash'; -const ITERATOR_SYMBOL = _.isFunction(Symbol) && Symbol.iterator; +const ITERATOR_SYMBOL = typeof Symbol !== 'undefined' && _.isFunction(Symbol) && Symbol.iterator; const OLD_ITERATOR_SYMBOL = '@@iterator'; /** @@ -8,17 +8,17 @@ const OLD_ITERATOR_SYMBOL = '@@iterator'; * @see https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Iteration_protocols */ export default (maybeIterable: any): boolean => { - let iterator; + let iterator; - if (!_.isObject(maybeIterable)) { - return false; - } + if (!_.isObject(maybeIterable)) { + return false; + } - if (ITERATOR_SYMBOL) { - iterator = maybeIterable[ITERATOR_SYMBOL]; - } else { - iterator = maybeIterable[OLD_ITERATOR_SYMBOL]; - } + if (ITERATOR_SYMBOL) { + iterator = maybeIterable[ITERATOR_SYMBOL]; + } else { + iterator = maybeIterable[OLD_ITERATOR_SYMBOL]; + } - return _.isFunction(iterator); + return _.isFunction(iterator); }; diff --git a/src/linkClass.js b/src/linkClass.js index 618561b..4ed3975 100644 --- a/src/linkClass.js +++ b/src/linkClass.js @@ -1,80 +1,106 @@ import _ from 'lodash'; -import React from 'react'; -import makeConfiguration from './makeConfiguration'; +import React, { + ReactElement +} from 'react'; +import objectUnfreeze from 'object-unfreeze'; import isIterable from './isIterable'; import parseStyleName from './parseStyleName'; import generateAppendClassName from './generateAppendClassName'; -import objectUnfreeze from 'object-unfreeze'; -import { - ReactElement -} from 'react'; +const linkArray = (array: Array, styles: Object, configuration: Object) => { + _.forEach(array, (value, index) => { + if (React.isValidElement(value)) { + // eslint-disable-next-line no-use-before-define + array[index] = linkElement(React.Children.only(value), styles, configuration); + } else if (_.isArray(value)) { + const unfreezedValue = Object.isFrozen(value) ? objectUnfreeze(value) : value; -let linkElement; + array[index] = linkArray(unfreezedValue, styles, configuration); + } + }); -linkElement = (element, styles, configuration) => { - let appendClassName, - elementIsFrozen, - elementShallowCopy; + return array; +}; - elementShallowCopy = element; +const linkElement = (element: ReactElement, styles: Object, configuration: Object): ReactElement => { + let appendClassName; + let elementShallowCopy; + + elementShallowCopy = element; + + if (Array.isArray(elementShallowCopy)) { + return elementShallowCopy.map((arrayElement) => { + return linkElement(arrayElement, styles, configuration); + }); + } + + const elementIsFrozen = Object.isFrozen && Object.isFrozen(elementShallowCopy); + const propsFrozen = Object.isFrozen && Object.isFrozen(elementShallowCopy.props); + const propsNotExtensible = Object.isExtensible && !Object.isExtensible(elementShallowCopy.props); + + if (elementIsFrozen) { + // https://github.com/facebook/react/blob/v0.13.3/src/classic/element/ReactElement.js#L131 + elementShallowCopy = objectUnfreeze(elementShallowCopy); + elementShallowCopy.props = objectUnfreeze(elementShallowCopy.props); + } else if (propsFrozen || propsNotExtensible) { + elementShallowCopy.props = objectUnfreeze(elementShallowCopy.props); + } + + const styleNames = parseStyleName(elementShallowCopy.props.styleName || '', configuration.allowMultiple); + const {children, ...restProps} = elementShallowCopy.props; + + if (React.isValidElement(children)) { + elementShallowCopy.props.children = linkElement(React.Children.only(children), styles, configuration); + } else if (_.isArray(children) || isIterable(children)) { + elementShallowCopy.props.children = linkArray(objectUnfreeze(children), styles, configuration); + } + + _.forEach(restProps, (propValue, propName) => { + if (React.isValidElement(propValue)) { + elementShallowCopy.props[propName] = linkElement(React.Children.only(propValue), styles, configuration); + } else if (_.isArray(propValue)) { + elementShallowCopy.props[propName] = linkArray(propValue, styles, configuration); + } + }); - if (Object.isFrozen && Object.isFrozen(elementShallowCopy)) { - elementIsFrozen = true; + if (styleNames.length) { + appendClassName = generateAppendClassName(styles, styleNames, configuration.handleNotFoundStyleName); - // https://github.com/facebook/react/blob/v0.13.3/src/classic/element/ReactElement.js#L131 - elementShallowCopy = objectUnfreeze(elementShallowCopy); - elementShallowCopy.props = objectUnfreeze(elementShallowCopy.props); - } + if (appendClassName) { + if (elementShallowCopy.props.className) { + appendClassName = elementShallowCopy.props.className + ' ' + appendClassName; + } - const styleNames = parseStyleName(elementShallowCopy.props.styleName || '', configuration.allowMultiple); - - if (React.isValidElement(elementShallowCopy.props.children)) { - elementShallowCopy.props.children = linkElement(React.Children.only(elementShallowCopy.props.children), styles, configuration); - } else if (_.isArray(elementShallowCopy.props.children) || isIterable(elementShallowCopy.props.children)) { - elementShallowCopy.props.children = React.Children.map(elementShallowCopy.props.children, (node) => { - if (React.isValidElement(node)) { - return linkElement(node, styles, configuration); - } else { - return node; - } - }); + elementShallowCopy.props.className = appendClassName; } + } - if (styleNames.length) { - appendClassName = generateAppendClassName(styles, styleNames, configuration.errorWhenNotFound); + delete elementShallowCopy.props.styleName; - if (appendClassName) { - if (elementShallowCopy.props.className) { - appendClassName = elementShallowCopy.props.className + ' ' + appendClassName; - } + if (elementIsFrozen) { + Object.freeze(elementShallowCopy.props); + Object.freeze(elementShallowCopy); + } else if (propsFrozen) { + Object.freeze(elementShallowCopy.props); + } - elementShallowCopy.props.className = appendClassName; - elementShallowCopy.props.styleName = null; - } - } + if (propsNotExtensible) { + Object.preventExtensions(elementShallowCopy.props); + } - if (elementIsFrozen) { - Object.freeze(elementShallowCopy.props); - Object.freeze(elementShallowCopy); - } - - return elementShallowCopy; + return elementShallowCopy; }; /** * @param {ReactElement} element * @param {Object} styles CSS modules class map. - * @param {CSSModules~Options} userConfiguration - * @returns {ReactElement} + * @param {CSSModules~Options} configuration */ -export default (element, styles = {}, userConfiguration) => { +export default (element: ReactElement, styles = {}, configuration = {}): ReactElement => { // @see https://github.com/gajus/react-css-modules/pull/30 - if (!_.isObject(element)) { - return element; - } - - const configuration = makeConfiguration(userConfiguration); + if (!_.isObject(element)) { + return element; + } - return linkElement(element, styles, configuration); + return linkElement(element, styles, configuration); }; diff --git a/src/makeConfiguration.js b/src/makeConfiguration.js index 22d0298..cc3169c 100644 --- a/src/makeConfiguration.js +++ b/src/makeConfiguration.js @@ -1,13 +1,10 @@ import _ from 'lodash'; -import Map from 'es6-map'; - -const userConfigurationIndex = new Map(); /** * @typedef CSSModules~Options * @see {@link https://github.com/gajus/react-css-modules#options} * @property {boolean} allowMultiple - * @property {boolean} errorWhenNotFound + * @property {string} handleNotFoundStyleName */ /** @@ -15,32 +12,26 @@ const userConfigurationIndex = new Map(); * @returns {CSSModules~Options} */ export default (userConfiguration = {}) => { - let configuration; - - configuration = userConfigurationIndex.get(userConfiguration); - - if (configuration) { - return configuration; + const configuration = { + allowMultiple: false, + handleNotFoundStyleName: 'throw' + }; + + _.forEach(userConfiguration, (value, name) => { + if (_.isUndefined(configuration[name])) { + throw new Error('Unknown configuration property "' + name + '".'); } - configuration = { - allowMultiple: false, - errorWhenNotFound: true - }; - - _.forEach(userConfiguration, (value, name) => { - if (_.isUndefined(configuration[name])) { - throw new Error('Unknown configuration property "' + name + '".'); - } - - if (!_.isBoolean(value)) { - throw new Error('"' + name + '" property value must be a boolean.'); - } + if (name === 'allowMultiple' && !_.isBoolean(value)) { + throw new Error('"allowMultiple" property value must be a boolean.'); + } - configuration[name] = value; - }); + if (name === 'handleNotFoundStyleName' && !_.includes(['throw', 'log', 'ignore'], value)) { + throw new Error('"handleNotFoundStyleName" property value must be "throw", "log" or "ignore".'); + } - userConfigurationIndex.set(userConfiguration, configuration); + configuration[name] = value; + }); - return configuration; + return configuration; }; diff --git a/src/parseStyleName.js b/src/parseStyleName.js index 39c328a..937b2d6 100644 --- a/src/parseStyleName.js +++ b/src/parseStyleName.js @@ -3,20 +3,20 @@ import _ from 'lodash'; const styleNameIndex = {}; export default (styleNamePropertyValue: string, allowMultiple: boolean): Array => { - let styleNames; + let styleNames; - if (styleNameIndex[styleNamePropertyValue]) { - styleNames = styleNameIndex[styleNamePropertyValue]; - } else { - styleNames = _.trim(styleNamePropertyValue).split(' '); - styleNames = _.filter(styleNames); + if (styleNameIndex[styleNamePropertyValue]) { + styleNames = styleNameIndex[styleNamePropertyValue]; + } else { + styleNames = _.trim(styleNamePropertyValue).split(/\s+/); + styleNames = _.filter(styleNames); - styleNameIndex[styleNamePropertyValue] = styleNames; - } + styleNameIndex[styleNamePropertyValue] = styleNames; + } - if (allowMultiple === false && styleNames.length > 1) { - throw new Error('ReactElement styleName property defines multiple module names ("' + styleNamePropertyValue + '").'); - } + if (allowMultiple === false && styleNames.length > 1) { + throw new Error('ReactElement styleName property defines multiple module names ("' + styleNamePropertyValue + '").'); + } - return styleNames; + return styleNames; }; diff --git a/src/renderNothing.js b/src/renderNothing.js new file mode 100644 index 0000000..93675fe --- /dev/null +++ b/src/renderNothing.js @@ -0,0 +1,7 @@ +import React from 'react'; + +export default function (version) { + const major = version.split('.')[0]; + + return parseInt(major, 10) < 15 ? React.createElement('noscript') : null; +} diff --git a/src/wrapStatelessFunction.js b/src/wrapStatelessFunction.js index 6ac5cea..b6b9162 100644 --- a/src/wrapStatelessFunction.js +++ b/src/wrapStatelessFunction.js @@ -1,39 +1,49 @@ +/* eslint-disable react/prop-types */ + import _ from 'lodash'; import React from 'react'; import linkClass from './linkClass'; +import renderNothing from './renderNothing'; /** * @see https://facebook.github.io/react/blog/2015/09/10/react-v0.14-rc1.html#stateless-function-components */ export default (Component: Function, defaultStyles: Object, options: Object): Function => { - const WrappedComponent = (props = {}, ...args) => { - let styles, - useProps; - - if (props.styles) { - useProps = props; - styles = props.styles; - } else if (_.isObject(defaultStyles)) { - useProps = _.assign({}, props, { - styles: defaultStyles - }); - - styles = defaultStyles; - } else { - useProps = props; - styles = {}; - } - - const renderResult = Component(useProps, ...args); - - if (renderResult) { - return linkClass(renderResult, styles, options); - } - - return React.createElement('noscript'); - }; - - _.assign(WrappedComponent, Component); - - return WrappedComponent; + const WrappedComponent = (props = {}, ...args) => { + let styles; + let useProps; + const hasDefaultstyles = _.isObject(defaultStyles); + + if (props.styles || hasDefaultstyles) { + useProps = Object.assign({}, props); + + if (props.styles) { + styles = props.styles; + } else { + styles = defaultStyles; + } + + Object.defineProperty(useProps, 'styles', { + configurable: true, + enumerable: false, + value: styles, + writable: false + }); + } else { + useProps = props; + styles = {}; + } + + const renderResult = Component(useProps, ...args); + + if (renderResult) { + return linkClass(renderResult, styles, options); + } + + return renderNothing(React.version); + }; + + _.assign(WrappedComponent, Component); + + return WrappedComponent; }; diff --git a/tests/SimpleMap.js b/tests/SimpleMap.js new file mode 100644 index 0000000..08ca888 --- /dev/null +++ b/tests/SimpleMap.js @@ -0,0 +1,38 @@ +import { + expect +} from 'chai'; +import SimpleMap from './../src/SimpleMap'; + +describe('SimpleMap', () => { + context('simple map with primitive or object as keys', () => { + const values = [ + [1, 'something'], + ['1', 'somethingElse'], + [{}, []], + [null, null] + ]; + + let map; + + beforeEach(() => { + map = new SimpleMap(); + }); + + it('should set', () => { + values.forEach(([key, value]) => { + map.set(key, value); + }); + expect(map.size).to.equal(values.length); + }); + + it('should get', () => { + values.forEach(([key, value]) => { + map.set(key, value); + }); + + values.forEach(([key, value]) => { + expect(map.get(key)).to.equal(value); + }); + }); + }); +}); diff --git a/tests/extendReactClass.js b/tests/extendReactClass.js index 2348bd5..01733ee 100644 --- a/tests/extendReactClass.js +++ b/tests/extendReactClass.js @@ -1,116 +1,167 @@ -/* eslint-disable max-nested-callbacks */ +/* eslint-disable max-nested-callbacks, react/prefer-stateless-function, react/prop-types, react/no-multi-comp, class-methods-use-this */ import { expect } from 'chai'; - import React from 'react'; import TestUtils from 'react-addons-test-utils'; +import shallowCompare from 'react-addons-shallow-compare'; import jsdom from 'jsdom'; import extendReactClass from './../src/extendReactClass'; describe('extendReactClass', () => { - beforeEach(() => { - global.document = jsdom.jsdom(''); + beforeEach(() => { + global.document = jsdom.jsdom(''); + + global.window = document.defaultView; + }); + context('using default styles', () => { + it('exposes styles through this.props.styles property', (done) => { + let Component; + + const styles = { + foo: 'foo-1' + }; + + Component = class extends React.Component { + render () { + expect(this.props.styles).to.equal(styles); + done(); + } + }; + + Component = extendReactClass(Component, styles); + + TestUtils.renderIntoDocument(); + }); + it('exposes non-enumerable styles property', (done) => { + let Component; + + const styles = { + foo: 'foo-1' + }; + + Component = class extends React.Component { + render () { + expect(this.props.propertyIsEnumerable('styles')).to.equal(false); + done(); + } + }; + + Component = extendReactClass(Component, styles); + + TestUtils.renderIntoDocument(); + }); + it('does not affect the other instance properties', (done) => { + let Component; - global.window = document.defaultView; + Component = class extends React.Component { + render () { + expect(this.props.bar).to.equal('baz'); + done(); + } + }; + + const styles = { + foo: 'foo-1' + }; + + Component = extendReactClass(Component, styles); + + TestUtils.renderIntoDocument(); }); - context('using default styles', () => { - it('exposes styles through this.props.styles property', (done) => { - let Component; - - const styles = { - foo: 'foo-1' - }; - - Component = class extends React.Component { - render () { - expect(this.props.styles).to.equal(styles); - done(); - } - }; - - Component = extendReactClass(Component, styles); - - TestUtils.renderIntoDocument(); - }); - it('does not affect the other instance properties', (done) => { - let Component; - - Component = class extends React.Component { - render () { - expect(this.props.bar).to.equal('baz'); - done(); - } - }; - - const styles = { - foo: 'foo-1' - }; - - Component = extendReactClass(Component, styles); - - TestUtils.renderIntoDocument(); - }); + it('does not affect pure-render logic', (done) => { + let Component; + let rendered; + + rendered = false; + + const styles = { + foo: 'foo-1' + }; + + Component = class extends React.Component { + shouldComponentUpdate (newProps) { + if (rendered) { + expect(shallowCompare(this.props, newProps)).to.equal(true); + + done(); + } + + return true; + } + + render () { + rendered = true; + } + }; + + Component = extendReactClass(Component, styles); + + const instance = TestUtils.renderIntoDocument(); + + // trigger shouldComponentUpdate + instance.setState({}); }); - context('overwriting default styles using "styles" property of the extended component', () => { - it('overwrites default styles', (done) => { - let Component; - - const styles = { - foo: 'foo-1' - }; - - Component = class extends React.Component { - render () { - expect(this.props.styles).to.equal(styles); - done(); - } - }; - - Component = extendReactClass(Component, { - bar: 'bar-0', - foo: 'foo-0' - }); - - TestUtils.renderIntoDocument(); - }); + }); + context('overwriting default styles using "styles" property of the extended component', () => { + it('overwrites default styles', (done) => { + let Component; + + const styles = { + foo: 'foo-1' + }; + + Component = class extends React.Component { + render () { + expect(this.props.styles).to.equal(styles); + done(); + } + }; + + Component = extendReactClass(Component, { + bar: 'bar-0', + foo: 'foo-0' + }); + + TestUtils.renderIntoDocument(); }); - context('rendering Component that returns null', () => { - it('generates