

Our Journey to Reducing Our Bundle Size by 90% with Embroider - Part II: Static invocation
(If you haven't read the first part of the series and would like to know about how we got to the point this post starts, please read the first part here.)
Our app was running on (or more precisely said, built by) Embroider, but we didn't have any of the optimizations it enables.
Since we wanted more out of Embroider than bragging rights (and a single blog post), and wanted our customers and developers to benefit from the change, we trodded on.
Static trees
Classic (pre-Embroider) Ember apps use a tool called Broccoli.js to bundle their assets. Its main concept to achieve that was that of (file) trees.
The first level of optimizations suggested by the Embroider docs was to switch on the two flags related to trees, staticAddonTrees
and staticAddonTestSupportTrees
:
return require('@embroider/compat').compatBuild(app, Webpack, {
staticAddonTestSupportTrees: true,
staticAddonTrees: true,
});
I was prepared to learn more about what they exactly do and how to fix the errors encountered along the way, but luckily, our app still built and worked fine with those flags 🎉 , so I can't share particular insights with regard to them.
Static invokables
The next step was to make the calling of components, helpers, and modifiers statically analyzable for Embroider (and, under the hood, Webpack) to be able to optimize the build.
We spent some time trying to switch on the respective flags (staticComponents
, staticHelpers
, and staticModifiers
) one by one before learning that it should all be done at the same time to simplify matters.
Curiously, the staticInvokables
flag was added to Embroider just a few days after we successfully deployed our PR. The new flag implicitly enables all the above flags – turning them on separately is now deprecated.
Making component calls statically analyzable
Unlocking optimizations with Embroider is all about making things "be knowable" at build time. Or, in other words, making things statically analyzable.
Ember's component
helper sticks out like a sore thumb in that regard. How would the build tool know which component the {{component myComp}}
expression can render if myComp
can have any value at runtime?
Well, it can't, and there is a whole page written about how to tackle these dynamic calls in the Embroider docs.
We had several instances where we rendered components dynamically based on some property. One such example was question filters:
{{component
this.componentName
...
}}
where this.componentName
returned the component path based on a property of the question:
get componentName() {
return `question-filter-${this.shortName}`;
}
However, this.shortName
can only have a limited set of values, so making it statically analyzable was straightforward:
import BooleanFilter from './question-filters/boolean';
import ChoiceFilter from './question-filters/choice';
import DateFilter from './question-filters/date';
import FileFilter from './question-filters/file';
import NumberFilter from './question-filters/number';
import RangeFilter from './question-filters/range';
import TextFilter from './question-filters/text';
import VideoFilter from './question-filters/video';
const componentMap = {
boolean: BooleanFilter,
choice: ChoiceFilter,
date: DateFilter,
file: FileFilter,
number: NumberFilter,
range: RangeFilter,
text: TextFilter,
video: VideoFilter,
};
export default class QuestionFilterComponent extends Component {
(...)
get filterComponent() {
return componentMap[this.shortName];
}
}
The template then invoked <this.filterComponent ...>
and both Embroider and we were happy.
ember-css-modules vs. staticComponents
Our happiness turned out to be short-lived when we bumped into ember-css-modules not playing along with the `staticComponents` flag.
We had 257 module CSS (and module SCSS) files, so it made sense to spend time thinking about how to move ahead.
This happened before AI-assisted development had teeth, so I started writing a codemod while my amazing coworker, Linus, began converting files based on the same pattern.
We rewrote the class file of the ActionBar
component as follows:
import Component from '@glimmer/component';
import styles from './action-bar.module.css';
export default class ActionBarComponent extends Component {
styles = styles;
}
The template could then use the styles
property from its class:
<div class={{concat "flex flex-col " this.styles.actionBar}}>
We left the action-bar.module.css
file, which had an .actionBar
rule, intact.
This is all well and good, but we needed to recreate the main feature of ember-css-modules
, scoping the CSS rules in the module to the component.
We can make Webpack handle that because it processes the CSS file during the import.
After some experimentation, we ended up with the following config in ember-cli-build.js
:
return require('@embroider/compat').compatBuild(app, Webpack, {
packagerOptions: {
cssLoaderOptions: {
modules: {
localIdentName: isProductionLikeBuild
? '[sha512:hash:base64:5]'
: '[path][name]__[local]',
}
},
webpackConfig: {
module: {
rules: [
{
test: /(node_modules\/\.embroider\/rewritten-app\/)(.*\.css)$/i,
use: [
{
loader: 'postcss-loader',
options: {
sourceMap: !isProductionLikeBuild,
postcssOptions: {
config: './postcss.config.js',
},
},
},
],
},
]
}
}
...
}
});
This transformed the .actionBar
rule in the CSS to .components-action-bar-module__actionBar
, thereby retaining component scoping (in production, it generates a random, 5-character-long string instead).
(Incidentally, the ember-css-modules
is one of the popular Ember add-ons the Ember Initiative is working to make Vite-compatible.)
staticHelpers and staticModifiers
We were again lucky, because we didn't have to make any helper or modifier calls static, as they already were.
When turning on these flags, the only thing we needed to change were ambiguous helper calls.
We had {{hide-scrollbar-without-jump}}
in our templates, and the following breaking error was thrown when building the app:
unsupported ambiguous syntax: "{{hide-scrollbar-without-jump}}" is ambiguous and could mean "{{this.hide-scrollbar-without-jump}}" or component "<HideScrollbarWithoutJump />" or helper "{{ (hide-scrollbar-without-jump) }}". Change it to one of those unambigous forms, or use a "disambiguate" packageRule to work around the problem if its in third-party code you cannot easily fix.
The solution is right there in the error message: we wrapped the helper calls in parentheses, and order was restored to the galaxy.
The results
With the static flags turned on (remember, if you do this now, you only need one flag: staticInvokables
), we built the app for production, and then used webpack-bundle-analyzer
to examine the differences in the output bundles.
The bundle containing our components was reduced from 6.13 MB to 3.59 MB, which is a 41% reduction. However, the main app bundle containing this one (along with templates, controllers, models, services, etc.) barely changed at all.
This made sense – we simply rearranged the components in the main bundle, but we were still loading the entire app upfront.
To reduce the bundle size of the main chunk, we needed the next level of optimization: splitting the app up by routes.
We embarked on this with high motivation and expectations.