Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[icons] Enable tree-shaking for SVG icons #2356

Closed
wants to merge 39 commits into from
Closed
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
f69463b
Pull buildPathsObject into buildPaths
reiv Apr 3, 2018
1bb1544
Generate individual icon modules
reiv Apr 3, 2018
dd95aef
Add IconModule interface
reiv Apr 3, 2018
11710f3
Generate allIcons.ts
reiv Apr 3, 2018
408f7d0
Disable tslint in generated icon modules
reiv Apr 3, 2018
d7d5a2f
Generate svgIcons.ts with individual icon exports
reiv Apr 3, 2018
679ef83
Export SVG icons from module root
reiv Apr 3, 2018
7a4d707
Refactor Icon to accept SVGIcon as icon prop
reiv Apr 3, 2018
c37e6de
Remove iconSvgPaths.ts export
reiv Apr 3, 2018
c45e459
Fix issue with at-loader on Windows
reiv Apr 3, 2018
2c07bf0
Don't return undefined in render
reiv Apr 3, 2018
41421aa
Bail if icon name is invalid
reiv Apr 3, 2018
60069f3
Refactor for code style
reiv Apr 5, 2018
2b41f08
Fix whitespace
reiv Apr 5, 2018
8f21779
Revert "Fix issue with at-loader on Windows"
reiv Apr 5, 2018
dc87059
Fix async reduce
reiv Apr 6, 2018
ac4f4cb
Move Icon from core to icons package
reiv Apr 6, 2018
ce86ff9
Duplicate minimal subset of core/common
reiv Apr 6, 2018
70846df
Move iconName.ts and svgIcon.ts to common
reiv Apr 7, 2018
7670126
Fix Classes export
reiv Apr 7, 2018
1dd1f8d
Split off IIconBaseProps from IIconProps
reiv Apr 7, 2018
8471203
Add IconPartial SFC type
reiv Apr 7, 2018
5e50f47
Add cross-repo @imports in Icon SCSS
reiv Apr 7, 2018
ef54225
Export svgIcons from root
reiv Apr 7, 2018
53b5ead
Rename SVGIcon type to SVGIconPaths
reiv Apr 7, 2018
bffc817
Pull SVG rendering logic from Icon into SVGIcon
reiv Apr 7, 2018
d4f2b89
Generate SVG icons as SFCs
reiv Apr 7, 2018
3359402
Simplify Icon to be a thin wrapper around SVGIcon
reiv Apr 7, 2018
6e5dbb0
Move tests from core to icons package
reiv Apr 7, 2018
770c186
Update tests
reiv Apr 7, 2018
5876f6f
Fix TagInput test
reiv Apr 7, 2018
6827ec1
Exclude generated files from test coverage
reiv Apr 7, 2018
10ce268
Merge branch 'develop' into rv/icon-tree-shaking
reiv Apr 17, 2018
bba99f7
Remove duplicate error message
reiv Apr 17, 2018
9708e64
Update copyrights
reiv Apr 17, 2018
2249959
Fix indentation
reiv Apr 17, 2018
aa1ba46
Update error message wording
reiv Apr 17, 2018
bbc3efb
Reference SVGIcon for icon sizes
reiv Apr 17, 2018
c5492e7
Move component-specific props out of common
reiv Apr 17, 2018
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion config/tsconfig.base.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"removeComments": false,
"sourceMap": false,
"stripInternal": true,
"target": "es5"
"target": "es5",
"typeRoots": ["../node_modules/@types"]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is this necessary now? we've never needed it in the past.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was just something I had to add on my end because of a bug with at-loader on Windows. In particular, it was causing Typescript to complain about being unable to find require(). There's some discussion of it here: PatrickJS/PatrickJS-starter#1371
It shouldn't affect anything else, but happy to split this off into a separate PR to reduce noise in the diff.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmmm core declares the require() type. we should probably just do the same in icons rather than changing the tsconfig.

Copy link
Contributor Author

@reiv reiv Apr 5, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

However, this also fixes a slew of errors that occur as a result of that at-loader + Windows interaction. For example, it doesn't pick up the typings for Mocha, so compiling unit tests gives me a wall of can't find "it/describe/etc" errors. I'll definitely submit this as a separate PR then.

}
}
5 changes: 5 additions & 0 deletions packages/core/src/common/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ export const HOTKEYS_WARN_DECORATOR_NO_METHOD = ns + ` @HotkeysTarget-decorated
export const HOTKEYS_WARN_DECORATOR_NEEDS_REACT_ELEMENT =
ns + ` "@HotkeysTarget-decorated components must return a single JSX.Element or an empty render.`;

export const ICON_STRING_NAMES_NOT_SUPPORTED =
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this used in core package? pretty sure it's safe to delete as it's defined in icons below.

ns +
` Specifying icon by string name is not supported because the BLUEPRINT_ICONS_TREE_SHAKING flag has been set.` +
` Icons must be imported as individual modules from the @blueprintjs/icons package.`;

export const NUMERIC_INPUT_MIN_MAX =
ns + ` <NumericInput> requires min to be strictly less than max if both are defined.`;
export const NUMERIC_INPUT_MINOR_STEP_SIZE_BOUND =
Expand Down
48 changes: 34 additions & 14 deletions packages/core/src/components/icon/icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,16 @@
import classNames from "classnames";
import * as React from "react";

import { IconName, IconSvgPaths16, IconSvgPaths20 } from "@blueprintjs/icons";
import { IconName, SVGIcon } from "@blueprintjs/icons";
import { Classes, IIntentProps, IProps } from "../../common";
import * as Errors from "../../common/errors";

let allIcons: { [key: string]: SVGIcon | undefined } | null = null;

if (!(global as any).BLUEPRINT_ICONS_TREE_SHAKING) {
// tslint:disable-next-line:no-var-requires
allIcons = require("@blueprintjs/icons");
}
export { IconName };

export interface IIconProps extends IIntentProps, IProps {
Expand All @@ -27,13 +34,13 @@ export interface IIconProps extends IIntentProps, IProps {
* be explicitly set to falsy values to render nothing.
*
* - If `null` or `undefined` or `false`, this component will render nothing.
* - If given an `IconName` (a string literal union of all icon names),
* - If given an `IconName` (a string literal union of all icon names) or an `SVGIcon`,
* that icon will be rendered as an `<svg>` with `<path>` tags.
* - If given a `JSX.Element`, that element will be rendered and _all other props on this component are ignored._
* This type is supported to simplify usage of this component in other Blueprint components.
* As a consumer, you should never use `<Icon icon={<element />}` directly; simply render `<element />` instead.
*/
icon: IconName | JSX.Element | false | null | undefined;
icon: IconName | SVGIcon | JSX.Element | false | null | undefined;

/**
* Size of the icon, in pixels.
Expand Down Expand Up @@ -62,20 +69,22 @@ export class Icon extends React.PureComponent<IIconProps & React.SVGAttributes<S
public static readonly SIZE_LARGE = 20;

public render() {
const { className, color, icon, iconSize = Icon.SIZE_STANDARD, intent, title = icon, ...svgProps } = this.props;
const { className, color, icon, iconSize = Icon.SIZE_STANDARD, intent, ...svgProps } = this.props;

if (icon == null || icon === false || React.isValidElement(icon)) {
return icon || null;
}

if (icon == null) {
const svgIcon = typeof icon === "string" ? this.getSvgIconFromName(icon) : (icon as SVGIcon);
if (svgIcon == null) {
return null;
} else if (typeof icon !== "string") {
return icon;
}

// choose which pixel grid is most appropriate for given icon size
const pixelGridSize = iconSize >= Icon.SIZE_LARGE ? Icon.SIZE_LARGE : Icon.SIZE_STANDARD;
const paths = this.renderSvgPaths(pixelGridSize, icon);
if (paths == null) {
return null;
}
const pathStrings = pixelGridSize === Icon.SIZE_STANDARD ? svgIcon[1] : svgIcon[2];

const paths = this.renderSvgPaths(pathStrings);

const classes = classNames(Classes.ICON, Classes.intentClass(intent), className);
const viewBox = `0 0 ${pixelGridSize} ${pixelGridSize}`;
Expand All @@ -86,6 +95,8 @@ export class Icon extends React.PureComponent<IIconProps & React.SVGAttributes<S
style = { ...style, fill: color };
}

const { title = svgIcon[0] } = this.props;

return (
<svg
{...svgProps}
Expand All @@ -102,9 +113,18 @@ export class Icon extends React.PureComponent<IIconProps & React.SVGAttributes<S
);
}

private renderSvgPaths(pathsSize: number, iconName: IconName) {
const svgPathsRecord = pathsSize === Icon.SIZE_STANDARD ? IconSvgPaths16 : IconSvgPaths20;
const pathStrings = svgPathsRecord[iconName];
private getSvgIconFromName(iconName: IconName) {
const camelCaseIconName = iconName
.split("-")
.reduce((result, word, i) => result + (i ? word[0].toUpperCase() + word.slice(1) : word));

if (allIcons == null) {
throw new Error(Errors.ICON_STRING_NAMES_NOT_SUPPORTED);
}
return allIcons[`${camelCaseIconName}Icon`];
}

private renderSvgPaths(pathStrings: string[] | null) {
if (pathStrings == null) {
return null;
}
Expand Down
4 changes: 3 additions & 1 deletion packages/icons/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,7 @@ import * as IconContents from "./generated/iconContents";
import * as IconNames from "./generated/iconNames";

export { IconContents, IconNames };
export { IconSvgPaths16, IconSvgPaths20 } from "./generated/iconSvgPaths";
export { IconName } from "./iconName";
export { SVGIcon } from "./svgIcon";

export * from "./generated/svgIcons";
11 changes: 11 additions & 0 deletions packages/icons/src/svgIcon.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/*
* Copyright 2018 Palantir Technologies, Inc. All rights reserved.
*/

/**
* Compact representation of an SVG icon.
* - `[0]`: name
* - `[1]`: 16px SVG path
* - `[2]`: 20px SVG path
*/
export type SVGIcon = [string, string[], string[]];
47 changes: 32 additions & 15 deletions packages/node-build-scripts/generate-icons-source
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
const fs = require("fs");
const path = require("path");
const SVGO = require("svgo");
const lodash = require("lodash");
const { COPYRIGHT_HEADER } = require("./constants");

const svgo = new SVGO({ plugins: [{ convertShapeToPath: { convertArcs: true } }] });
Expand Down Expand Up @@ -51,17 +52,21 @@ writeLinesToFile("iconNames.ts", ...exportIconConsts(icon => icon.iconName));

(async () => {
// SVG path strings. IIFE to unwrap async.
const iconSvgPaths16 = await buildPaths(16);
const iconSvgPaths20 = await buildPaths(20);

writeLinesToFile(
"iconSvgPaths.ts",
'import { IconName } from "../iconName";',
"",
"export const IconSvgPaths16: Record<IconName, string[]> = {",
...(await buildPathsObject("IconSvgPaths", 16)),
"};",
"svgIcons.ts",
"// tslint:disable:prettier",
'import { SVGIcon } from "../svgIcon"',
"",
"export const IconSvgPaths20: Record<IconName, string[]> = {",
...(await buildPathsObject("IconSvgPaths", 20)),
"};",
...ICONS_METADATA.map(({iconName}) => {
const alias = lodash.camelCase(iconName);
return `export const ${alias}Icon: SVGIcon = [`
+ `"${iconName}"` + ","
+ iconSvgPaths16[iconName] + ","
+ iconSvgPaths20[iconName] + "]"
}),
);
})();

Expand Down Expand Up @@ -101,22 +106,34 @@ function exportIconConsts(valueGetter) {
return ICONS_METADATA.map(icon => `export const ${toEnumName(icon)} = "${valueGetter(icon)}";`);
}

/**
/*
* Loads SVG file for each icon, extracts path strings `d="path-string"`,
* and constructs map of icon name to array of path strings.
* @param {string} objectName
* and returns a map of icon names to stringified arrays of path strings.
* @param {16 | 20} size
*/
async function buildPathsObject(objectName, size) {
return Promise.all(
async function buildPaths(size) {
const paths = {};
await Promise.all(
ICONS_METADATA.map(async icon => {
const filepath = path.resolve(__dirname, `../../resources/icons/${size}px/${icon.iconName}.svg`);
const svg = fs.readFileSync(filepath, "utf-8");
const pathStrings = await svgo
.optimize(svg, { path: filepath })
.then(({ data }) => data.match(/ d="[^"]+"/g) || [])
.then(paths => paths.map(s => s.slice(3)));
return ` "${icon.iconName}": [${pathStrings.join(",\n")}],`;
paths[icon.iconName] = `[${pathStrings.join(",\n")}]`;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is now a reduce, not a map.

ICONS_METADATA.reduce(async (paths, icon) => { ... }, {})

}),
);
return paths;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

something weird going on with whitespace here?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

indentation?

}

/**
* Returns stringified contents of a map of icon names to arrays of path
* strings.
* @param {Object} paths - paths as returned by buildPaths
*/
function buildPathsObject(paths) {
return Object.keys(paths).map(iconName => {
return ` "${iconName}": ${paths[iconName]},`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

inline! iconName => "template-string"

});
}