From d0e17316b965696328f0d76f1ff81e8c9c5d3802 Mon Sep 17 00:00:00 2001
From: Ian Sutherland <ian@iansutherland.ca>
Date: Fri, 2 Feb 2018 12:56:57 -0700
Subject: [PATCH] Named asset import for SVG files (#3907)

* Add named asset import for svg files via babel plugin and webpack loader.

* Fix failing e2e test

* Switched to svgr loader

* Updated SVG component test

* Disable named asset import plugin in test environment

* Added tests for including SVG in CSS

* Update tests

* Moved babel plugin config into webpack config
---
 .../babel-plugin-named-asset-import/index.js  | 62 +++++++++++++++++++
 .../package.json                              | 17 +++++
 .../config/webpack.config.dev.js              | 37 ++++-------
 .../config/webpack.config.prod.js             | 37 ++++-------
 .../kitchensink/integration/webpack.test.js   | 16 +++++
 .../fixtures/kitchensink/src/App.js           | 16 ++++-
 .../src/features/webpack/SvgComponent.js      |  2 +-
 .../src/features/webpack/SvgInCss.js          |  4 ++
 .../src/features/webpack/SvgInCss.test.js     | 10 +++
 .../src/features/webpack/assets/svg.css       |  3 +
 packages/react-scripts/package.json           |  3 +-
 11 files changed, 152 insertions(+), 55 deletions(-)
 create mode 100644 packages/babel-plugin-named-asset-import/index.js
 create mode 100644 packages/babel-plugin-named-asset-import/package.json
 create mode 100644 packages/react-scripts/fixtures/kitchensink/src/features/webpack/SvgInCss.js
 create mode 100644 packages/react-scripts/fixtures/kitchensink/src/features/webpack/SvgInCss.test.js
 create mode 100644 packages/react-scripts/fixtures/kitchensink/src/features/webpack/assets/svg.css

diff --git a/packages/babel-plugin-named-asset-import/index.js b/packages/babel-plugin-named-asset-import/index.js
new file mode 100644
index 000000000..6fd919bc6
--- /dev/null
+++ b/packages/babel-plugin-named-asset-import/index.js
@@ -0,0 +1,62 @@
+'use strict';
+
+const { extname } = require('path');
+
+function namedAssetImportPlugin({ types: t }) {
+  const visited = new WeakSet();
+
+  return {
+    visitor: {
+      ImportDeclaration(path, { opts: { loaderMap } }) {
+        const sourcePath = path.node.source.value;
+        const ext = extname(sourcePath).substr(1);
+
+        if (visited.has(path.node) || sourcePath.indexOf('!') !== -1) {
+          return;
+        }
+
+        if (loaderMap[ext]) {
+          path.replaceWithMultiple(
+            path.node.specifiers.map(specifier => {
+              if (t.isImportDefaultSpecifier(specifier)) {
+                const newDefaultImport = t.importDeclaration(
+                  [
+                    t.importDefaultSpecifier(
+                      t.identifier(specifier.local.name)
+                    ),
+                  ],
+                  t.stringLiteral(sourcePath)
+                );
+
+                visited.add(newDefaultImport);
+                return newDefaultImport;
+              }
+
+              const newImport = t.importDeclaration(
+                [
+                  t.importSpecifier(
+                    t.identifier(specifier.local.name),
+                    t.identifier(specifier.imported.name)
+                  ),
+                ],
+                t.stringLiteral(
+                  loaderMap[ext][specifier.imported.name]
+                    ? loaderMap[ext][specifier.imported.name].replace(
+                        /\[path\]/,
+                        sourcePath
+                      )
+                    : sourcePath
+                )
+              );
+
+              visited.add(newImport);
+              return newImport;
+            })
+          );
+        }
+      },
+    },
+  };
+}
+
+module.exports = namedAssetImportPlugin;
diff --git a/packages/babel-plugin-named-asset-import/package.json b/packages/babel-plugin-named-asset-import/package.json
new file mode 100644
index 000000000..9c586ac57
--- /dev/null
+++ b/packages/babel-plugin-named-asset-import/package.json
@@ -0,0 +1,17 @@
+{
+  "name": "babel-plugin-named-asset-import",
+  "version": "0.1.0",
+  "description": "Babel plugin for named asset imports in Create React App",
+  "repository": "facebookincubator/create-react-app",
+  "license": "MIT",
+  "bugs": {
+    "url": "https://github.com/facebookincubator/create-react-app/issues"
+  },
+  "main": "index.js",
+  "files": [
+    "index.js"
+  ],
+  "peerDependencies": {
+    "@babel/core": "7.0.0-beta.38"
+  }
+}
diff --git a/packages/react-scripts/config/webpack.config.dev.js b/packages/react-scripts/config/webpack.config.dev.js
index 66f07baa2..bd5d218cc 100644
--- a/packages/react-scripts/config/webpack.config.dev.js
+++ b/packages/react-scripts/config/webpack.config.dev.js
@@ -192,6 +192,18 @@ module.exports = {
                   babelrc: false,
                   // @remove-on-eject-end
                   presets: [require.resolve('babel-preset-react-app')],
+                  plugins: [
+                    [
+                      require.resolve('babel-plugin-named-asset-import'),
+                      {
+                        loaderMap: {
+                          svg: {
+                            ReactComponent: 'svgr/webpack![path]',
+                          },
+                        },
+                      },
+                    ],
+                  ],
                   // This is a feature of `babel-loader` for webpack (not Babel itself).
                   // It enables caching results in ./node_modules/.cache/babel-loader/
                   // directory for faster rebuilds.
@@ -266,31 +278,6 @@ module.exports = {
               },
             ],
           },
-          // Allows you to use two kinds of imports for SVG:
-          // import logoUrl from './logo.svg'; gives you the URL.
-          // import { ReactComponent as Logo } from './logo.svg'; gives you a component.
-          {
-            test: /\.svg$/,
-            use: [
-              {
-                loader: require.resolve('babel-loader'),
-                options: {
-                  // @remove-on-eject-begin
-                  babelrc: false,
-                  // @remove-on-eject-end
-                  presets: [require.resolve('babel-preset-react-app')],
-                  cacheDirectory: true,
-                },
-              },
-              require.resolve('svgr/webpack'),
-              {
-                loader: require.resolve('file-loader'),
-                options: {
-                  name: 'static/media/[name].[hash:8].[ext]',
-                },
-              },
-            ],
-          },
           // "file" loader makes sure those assets get served by WebpackDevServer.
           // When you `import` an asset, you get its (virtual) filename.
           // In production, they would get copied to the `build` folder.
diff --git a/packages/react-scripts/config/webpack.config.prod.js b/packages/react-scripts/config/webpack.config.prod.js
index 1d9a617c8..6c9a879e8 100644
--- a/packages/react-scripts/config/webpack.config.prod.js
+++ b/packages/react-scripts/config/webpack.config.prod.js
@@ -200,6 +200,18 @@ module.exports = {
                   babelrc: false,
                   // @remove-on-eject-end
                   presets: [require.resolve('babel-preset-react-app')],
+                  plugins: [
+                    [
+                      require.resolve('babel-plugin-named-asset-import'),
+                      {
+                        loaderMap: {
+                          svg: {
+                            ReactComponent: 'svgr/webpack![path]',
+                          },
+                        },
+                      },
+                    ],
+                  ],
                   compact: true,
                   highlightCode: true,
                 },
@@ -308,31 +320,6 @@ module.exports = {
             ),
             // Note: this won't work without `new ExtractTextPlugin()` in `plugins`.
           },
-          // Allows you to use two kinds of imports for SVG:
-          // import logoUrl from './logo.svg'; gives you the URL.
-          // import { ReactComponent as Logo } from './logo.svg'; gives you a component.
-          {
-            test: /\.svg$/,
-            use: [
-              {
-                loader: require.resolve('babel-loader'),
-                options: {
-                  // @remove-on-eject-begin
-                  babelrc: false,
-                  // @remove-on-eject-end
-                  presets: [require.resolve('babel-preset-react-app')],
-                  cacheDirectory: true,
-                },
-              },
-              require.resolve('svgr/webpack'),
-              {
-                loader: require.resolve('file-loader'),
-                options: {
-                  name: 'static/media/[name].[hash:8].[ext]',
-                },
-              },
-            ],
-          },
           // "file" loader makes sure assets end up in the `build` folder.
           // When you `import` an asset, you get its filename.
           // This loader doesn't use a "test" so it will catch all modules
diff --git a/packages/react-scripts/fixtures/kitchensink/integration/webpack.test.js b/packages/react-scripts/fixtures/kitchensink/integration/webpack.test.js
index 06ec83602..e479be4b8 100644
--- a/packages/react-scripts/fixtures/kitchensink/integration/webpack.test.js
+++ b/packages/react-scripts/fixtures/kitchensink/integration/webpack.test.js
@@ -71,6 +71,22 @@ describe('Integration', () => {
       );
     });
 
+    it('svg component', async () => {
+      const doc = await initDOM('svg-component');
+
+      expect(doc.getElementById('feature-svg-component').textContent).to.equal(
+        ''
+      );
+    });
+
+    it('svg in css', async () => {
+      const doc = await initDOM('svg-in-css');
+
+      expect(
+        doc.getElementsByTagName('style')[0].textContent.replace(/\s/g, '')
+      ).to.match(/\/static\/media\/logo\..+\.svg/);
+    });
+
     it('unknown ext inclusion', async () => {
       const doc = await initDOM('unknown-ext-inclusion');
 
diff --git a/packages/react-scripts/fixtures/kitchensink/src/App.js b/packages/react-scripts/fixtures/kitchensink/src/App.js
index d1affb48a..750f8a90b 100644
--- a/packages/react-scripts/fixtures/kitchensink/src/App.js
+++ b/packages/react-scripts/fixtures/kitchensink/src/App.js
@@ -82,9 +82,9 @@ class App extends Component {
         );
         break;
       case 'css-modules-inclusion':
-        import(
-          './features/webpack/CssModulesInclusion'
-        ).then(f => this.setFeature(f.default));
+        import('./features/webpack/CssModulesInclusion').then(f =>
+          this.setFeature(f.default)
+        );
         break;
       case 'custom-interpolation':
         import('./features/syntax/CustomInterpolation').then(f =>
@@ -174,6 +174,16 @@ class App extends Component {
           this.setFeature(f.default)
         );
         break;
+      case 'svg-component':
+        import('./features/webpack/SvgComponent').then(f =>
+          this.setFeature(f.default)
+        );
+        break;
+      case 'svg-in-css':
+        import('./features/webpack/SvgInCss').then(f =>
+          this.setFeature(f.default)
+        );
+        break;
       case 'template-interpolation':
         import('./features/syntax/TemplateInterpolation').then(f =>
           this.setFeature(f.default)
diff --git a/packages/react-scripts/fixtures/kitchensink/src/features/webpack/SvgComponent.js b/packages/react-scripts/fixtures/kitchensink/src/features/webpack/SvgComponent.js
index 0eb06a027..62bad3b10 100644
--- a/packages/react-scripts/fixtures/kitchensink/src/features/webpack/SvgComponent.js
+++ b/packages/react-scripts/fixtures/kitchensink/src/features/webpack/SvgComponent.js
@@ -8,4 +8,4 @@
 import React from 'react';
 import { ReactComponent as Logo } from './assets/logo.svg';
 
-export default () => <Logo />;
+export default () => <Logo id="feature-svg-component" />;
diff --git a/packages/react-scripts/fixtures/kitchensink/src/features/webpack/SvgInCss.js b/packages/react-scripts/fixtures/kitchensink/src/features/webpack/SvgInCss.js
new file mode 100644
index 000000000..edf341379
--- /dev/null
+++ b/packages/react-scripts/fixtures/kitchensink/src/features/webpack/SvgInCss.js
@@ -0,0 +1,4 @@
+import React from 'react';
+import './assets/svg.css';
+
+export default () => <div id="feature-svg-in-css" />;
diff --git a/packages/react-scripts/fixtures/kitchensink/src/features/webpack/SvgInCss.test.js b/packages/react-scripts/fixtures/kitchensink/src/features/webpack/SvgInCss.test.js
new file mode 100644
index 000000000..f0c0bd683
--- /dev/null
+++ b/packages/react-scripts/fixtures/kitchensink/src/features/webpack/SvgInCss.test.js
@@ -0,0 +1,10 @@
+import React from 'react';
+import ReactDOM from 'react-dom';
+import SvgInCss from './SvgInCss';
+
+describe('svg in css', () => {
+  it('renders without crashing', () => {
+    const div = document.createElement('div');
+    ReactDOM.render(<SvgInCss />, div);
+  });
+});
diff --git a/packages/react-scripts/fixtures/kitchensink/src/features/webpack/assets/svg.css b/packages/react-scripts/fixtures/kitchensink/src/features/webpack/assets/svg.css
new file mode 100644
index 000000000..ad0ff9365
--- /dev/null
+++ b/packages/react-scripts/fixtures/kitchensink/src/features/webpack/assets/svg.css
@@ -0,0 +1,3 @@
+#feature-svg-in-css {
+  background-image: url("./logo.svg");
+}
diff --git a/packages/react-scripts/package.json b/packages/react-scripts/package.json
index 9bb5a1a07..fd876f502 100644
--- a/packages/react-scripts/package.json
+++ b/packages/react-scripts/package.json
@@ -28,6 +28,7 @@
     "babel-eslint": "8.2.1",
     "babel-jest": "22.1.0",
     "babel-loader": "8.0.0-beta.0",
+    "babel-plugin-named-asset-import": "^0.1.0",
     "babel-preset-react-app": "^3.1.1",
     "case-sensitive-paths-webpack-plugin": "2.1.1",
     "chalk": "2.3.0",
@@ -56,7 +57,7 @@
     "raf": "3.4.0",
     "react-dev-utils": "^5.0.0",
     "style-loader": "0.19.1",
-    "svgr": "1.6.0",
+    "svgr": "1.8.1",
     "sw-precache-webpack-plugin": "0.11.4",
     "thread-loader": "1.1.2",
     "uglifyjs-webpack-plugin": "1.1.6",
-- 
GitLab