Skip to content

Commit

Permalink
[v5] [icons] feat: reduce bundle size, load paths instead of componen…
Browse files Browse the repository at this point in the history
…ts (#6212)
  • Loading branch information
adidahiya authored Jun 9, 2023
1 parent 0f9e12e commit c8f6bae
Show file tree
Hide file tree
Showing 11 changed files with 204 additions and 121 deletions.
3 changes: 0 additions & 3 deletions packages/core/src/components/icon/_icon.scss
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,6 @@
> svg {
// prevent extra vertical whitespace
display: block;
// paths parsed by generate-icon-paths.js are mirrored vertically, so we need
// to flip them upright here
transform: scaleY(-1);

// inherit text color unless explicit fill is set
&:not([fill]) {
Expand Down
34 changes: 19 additions & 15 deletions packages/core/src/components/icon/icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
import classNames from "classnames";
import * as React from "react";

import { IconComponent, IconName, Icons, IconSize, SVGIconProps } from "@blueprintjs/icons";
import { IconName, IconPaths, Icons, IconSize, SVGIconContainer, SVGIconProps } from "@blueprintjs/icons";

import { Classes, DISPLAYNAME_PREFIX, IntentProps, MaybeElement, Props, removeNonHTMLProps } from "../../common";

Expand Down Expand Up @@ -88,9 +88,9 @@ export const Icon: React.FC<IconProps> = React.forwardRef<any, IconProps>((props
htmlTitle,
...htmlProps
} = props;
const [Component, setIconComponent] = React.useState<IconComponent>();
const [iconPaths, setIconPaths] = React.useState<IconPaths>();
// eslint-disable-next-line deprecation/deprecation
const size = props.size ?? props.iconSize;
const size = (props.size ?? props.iconSize)!;

React.useEffect(() => {
let shouldCancelIconLoading = false;
Expand All @@ -99,33 +99,33 @@ export const Icon: React.FC<IconProps> = React.forwardRef<any, IconProps>((props
// N.B. when `autoLoad={true}`, we can't rely on simply calling Icons.load() here to re-load an icon module
// which has already been loaded & cached, since it may have been loaded with special loading options which
// this component knows nothing about.
const loadedIconComponent = Icons.getComponent(icon);
const loadedIconPaths = Icons.getPaths(icon, size);

if (loadedIconComponent !== undefined) {
setIconComponent(loadedIconComponent);
if (loadedIconPaths !== undefined) {
setIconPaths(loadedIconPaths);
} else if (autoLoad) {
Icons.load(icon)
Icons.load(icon, size)
.then(() => {
// if this effect expired by the time icon loaded, then don't set state
if (!shouldCancelIconLoading) {
setIconComponent(Icons.getComponent(icon));
setIconPaths(Icons.getPaths(icon, size));
}
})
.catch(reason => {
console.error(`[Blueprint] Icon '${icon}' could not be loaded.`, reason);
console.error(`[Blueprint] Icon '${icon}' (${size}px) could not be loaded.`, reason);
});
} else {
console.error(
`[Blueprint] Icon '${icon}' is not loaded yet and autoLoad={false}, did you call Icons.load('${icon}')?`,
`[Blueprint] Icon '${icon}' (${size}px) is not loaded yet and autoLoad={false}, did you call Icons.load('${icon}', ${size})?`,
);
}
}
return () => {
shouldCancelIconLoading = true;
};
}, [autoLoad, icon]);
}, [autoLoad, icon, size]);

if (Component == null) {
if (iconPaths == null) {
// fall back to icon font if unloaded or unable to load SVG implementation
const sizeClass =
size === IconSize.STANDARD
Expand All @@ -148,19 +148,23 @@ export const Icon: React.FC<IconProps> = React.forwardRef<any, IconProps>((props
title: htmlTitle,
});
} else {
const pathElements = iconPaths.map((d, i) => <path d={d} key={i} fillRule="evenodd" />);
return (
<Component
// don't forward Classes.iconClass(icon) here, since the component template will render that class
<SVGIconContainer
// don't forward Classes.iconClass(icon) here, since the container will render that class
className={classNames(Classes.intentClass(intent), className)}
color={color}
iconName={icon}
size={size}
tagName={tagName}
title={title}
htmlTitle={htmlTitle}
ref={ref}
svgProps={svgProps}
{...removeNonHTMLProps(htmlProps)}
/>
>
{pathElements}
</SVGIconContainer>
);
}
});
Expand Down
10 changes: 6 additions & 4 deletions packages/core/test/icon/iconTests.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ import { mount } from "enzyme";
import * as React from "react";
import Sinon, { stub } from "sinon";

import { Add, Airplane, Calendar, Graph, IconName, Icons, IconSize } from "@blueprintjs/icons";
import { IconName, Icons, IconSize } from "@blueprintjs/icons";
// eslint-disable-next-line @typescript-eslint/tslint/config
import { Add, Airplane, Calendar, Graph } from "@blueprintjs/icons/lib/cjs/generated/16px/paths";

import { Classes, Icon, IconProps, Intent } from "../../src";

Expand All @@ -29,12 +31,12 @@ describe("<Icon>", () => {
before(() => {
stub(Icons, "load").resolves(undefined);
// stub the dynamic icon loader with a synchronous, static one
iconLoader = stub(Icons, "getComponent");
iconLoader = stub(Icons, "getPaths");
iconLoader.returns(undefined);
iconLoader.withArgs("graph").returns(Graph);
iconLoader.withArgs("add").returns(Add);
iconLoader.withArgs("calendar").returns(Calendar);
iconLoader.withArgs("airplane").returns(Airplane);
iconLoader.withArgs("calendar").returns(Calendar);
iconLoader.withArgs("graph").returns(Graph);
});

afterEach(() => {
Expand Down
4 changes: 2 additions & 2 deletions packages/docs-app/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,12 @@ const tagRenderers = {

// this compiles all the icon modules into this chunk, so async Icon.load() calls don't block later
Icons.loadAll({
loader: async name => {
loader: async (name, size) => {
return (
await import(
/* webpackInclude: /\.js$/ */
/* webpackMode: "eager" */
`@blueprintjs/icons/lib/esm/generated/components/${name}`
`@blueprintjs/icons/lib/esm/generated/${size}px/paths/${name}`
)
).default;
},
Expand Down
83 changes: 20 additions & 63 deletions packages/icons/scripts/iconComponent.tsx.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -13,73 +13,30 @@
* limitations under the License.
*/

import classNames from "classnames";
import * as React from "react";
import type { SVGIconProps } from "../../svgIconProps";
import { IconSize } from "../../iconSize";
import * as Classes from "../../classes";
import { uniqueId } from "../../jsUtils";
import { SVGIconContainer } from "../../svgIconContainer";

export const {{pascalCase iconName}}: React.FC<SVGIconProps> = React.forwardRef<any, SVGIconProps>(({
className,
color,
size = IconSize.STANDARD,
title,
htmlTitle,
svgProps,
tagName = "span",
...htmlProps
}, ref) => {
const isLarge = size >= IconSize.LARGE;
const pixelGridSize = isLarge ? IconSize.LARGE : IconSize.STANDARD;
const viewBox = `0 0 ${pixelGridSize} ${pixelGridSize}`;
const titleId = uniqueId("iconTitle");
const path = (
<path
d={
isLarge
? "{{icon20pxPath}}"
: "{{icon16pxPath}}"
}
fillRule="evenodd"
transform="scale({{pathScaleFactor}} {{pathScaleFactor}})"
/>
);

if (tagName === null) {
return (
<svg
aria-labelledby={title ? titleId : undefined}
data-icon="{{iconName}}"
fill={color}
height={size}
ref={ref}
role="img"
viewBox={viewBox}
width={size}
{...svgProps}
{...htmlProps}
>
{title && <title id={titleId}>{title}</title>}
{path}
</svg>
);
} else {
return React.createElement(
tagName,
{
...htmlProps,
"aria-hidden": title ? undefined : true,
className: classNames(Classes.ICON, `${Classes.ICON}-{{iconName}}`, className),
ref,
title: htmlTitle,
},
<svg fill={color} data-icon="{{iconName}}" width={size} height={size} role="img" viewBox={viewBox} {...svgProps}>
{title && <title>{title}</title>}
{path}
</svg>
);
}
export const {{pascalCase iconName}}: React.FC<SVGIconProps> = React.forwardRef<any, SVGIconProps>((props, ref) => {
const translation = `${-1 * props.size! / {{pathScaleFactor}} / 2}`;
return (
<SVGIconContainer iconName="{{iconName}}" ref={ref} {...props}>
<path
d={
props.size! < IconSize.LARGE
? "{{icon16pxPath}}"
: "{{icon20pxPath}}"
}
fillRule="evenodd"
transform-origin="center"
transform={`scale({{pathScaleFactor}}, -{{pathScaleFactor}}) translate(${translation}, ${translation})`}
/>
</SVGIconContainer>
)
});
{{pascalCase iconName}}.defaultProps = {
size: IconSize.STANDARD,
};
{{pascalCase iconName}}.displayName = `Blueprint5.Icon.{{pascalCase iconName}}`;
export default {{pascalCase iconName}};
66 changes: 38 additions & 28 deletions packages/icons/src/iconLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,36 +14,33 @@
* limitations under the License.
*/

import * as React from "react";

import { IconName, IconNames } from "./iconNames";
import { IconSize } from "./iconSize";
import type { IconPaths } from "./iconSvgPaths";
import { wrapWithTimer } from "./loaderUtils";
import { SVGIconProps } from "./svgIconProps";

export type IconComponent = React.FC<SVGIconProps>;

/** Given an icon name and size, loads the icon component from the generated source module in this package. */
export type IconComponentLoader = (iconName: IconName) => Promise<IconComponent>;
/** Given an icon name and size, loads the icon paths that define it. */
export type IconPathsLoader = (iconName: IconName, iconSize: IconSize) => Promise<IconPaths>;

export interface IconLoaderOptions {
/*
* Optional custom loader for icon contents, useful if the default loader which uses a
* Optional custom loader for icon path, useful if the default loader which uses a
* webpack-configured dynamic import() is not suitable for some reason.
*/
loader?: IconComponentLoader;
loader?: IconPathsLoader;
}

/**
* The default icon contents loader implementation, optimized for webpack.
* The default icon paths loader implementation, optimized for webpack.
*
* @see https://webpack.js.org/api/module-methods/#magic-comments for dynamic import() reference
*/
const defaultIconContentsLoader: IconComponentLoader = async name => {
const defaultIconPathsLoader: IconPathsLoader = async (name, size) => {
return (
await import(
/* webpackInclude: /\.js$/ */
/* webpackMode: "lazy-once" */
`./generated/components/${name}`
`./generated/${size}px/paths/${name}`
)
).default;
};
Expand All @@ -53,24 +50,27 @@ const defaultIconContentsLoader: IconComponentLoader = async name => {
*/
export class Icons {
/** @internal */
public loadedIcons: Map<IconName, IconComponent> = new Map();
public loadedIconPaths16: Map<IconName, IconPaths> = new Map();

/** @internal */
public loadedIconPaths20: Map<IconName, IconPaths> = new Map();

/**
* Load a single icon for use in Blueprint components.
*/
public static async load(icon: IconName, options?: IconLoaderOptions): Promise<void>;
public static async load(icon: IconName, size: IconSize, options?: IconLoaderOptions): Promise<void>;
/**
* Load a set of icons for use in Blueprint components.
*/
// buggy rule implementation for TS
// eslint-disable-next-line @typescript-eslint/unified-signatures
public static async load(icons: IconName[], options?: IconLoaderOptions): Promise<void>;
public static async load(icons: IconName | IconName[], options?: IconLoaderOptions) {
public static async load(icons: IconName[], size: number, options?: IconLoaderOptions): Promise<void>;
public static async load(icons: IconName | IconName[], size: number, options?: IconLoaderOptions) {
if (!Array.isArray(icons)) {
icons = [icons];
}

await Promise.all(icons.map(icon => this.loadImpl(icon, options)));
await Promise.all(icons.map(icon => this.loadImpl(icon, size, options)));
return;
}

Expand All @@ -79,39 +79,49 @@ export class Icons {
*/
public static async loadAll(options?: IconLoaderOptions) {
const allIcons = Object.values(IconNames);
wrapWithTimer(`[Blueprint] loading all icons`, () => this.load(allIcons, options));
wrapWithTimer(`[Blueprint] loading all icons`, async () => {
await Promise.all([
this.load(allIcons, IconSize.STANDARD, options),
this.load(allIcons, IconSize.LARGE, options),
]);
});
}

/**
* Get the icon SVG component. Returns `undefined` if the icon has not been loaded yet.
* Get the icon SVG paths. Returns `undefined` if the icon has not been loaded yet.
*/
public static getComponent(icon: IconName): IconComponent | undefined {
public static getPaths(icon: IconName, size: IconSize): IconPaths | undefined {
if (!this.isValidIconName(icon)) {
// don't warn, since this.load() will have warned already
return undefined;
}

return singleton.loadedIcons.get(icon);
const loadedIcons = size < IconSize.LARGE ? singleton.loadedIconPaths16 : singleton.loadedIconPaths20;
return loadedIcons.get(icon);
}

private static async loadImpl(icon: IconName, options?: IconLoaderOptions) {
private static async loadImpl(icon: IconName, size: number, options?: IconLoaderOptions) {
if (!this.isValidIconName(icon)) {
console.error(`[Blueprint] Unknown icon '${icon}'`);
return;
} else if (singleton.loadedIcons.has(icon)) {
}

const loadedIcons = size < IconSize.LARGE ? singleton.loadedIconPaths16 : singleton.loadedIconPaths20;

if (loadedIcons.has(icon)) {
// already loaded, no-op
return;
}

// use a custom loader if specified, otherwise use the default one
const load = options?.loader ?? defaultIconContentsLoader;
const load = options?.loader ?? defaultIconPathsLoader;

try {
// load both sizes in parallel
const component = await load(icon);
singleton.loadedIcons.set(icon, component);
const supportedSize = size < IconSize.LARGE ? IconSize.STANDARD : IconSize.LARGE;
const paths = await load(icon, supportedSize);
loadedIcons.set(icon, paths);
} catch (e) {
console.error(`[Blueprint] Unable to load icon '${icon}'`, e);
console.error(`[Blueprint] Unable to load ${size}px icon '${icon}'`, e);
}
}

Expand Down
Loading

0 comments on commit c8f6bae

Please sign in to comment.