Black Lives Matter

Kevin Wilde

Code Splitting with React in a Symfony + Assetic codebase

Course Hero’s main website is (for the most part) one large Symfony application. Symfony is a PHP framework, though the intricacies of Symfony will not be important for this article. The one thing we need to cover is the role that Assetic plays within a Symfony application.

Assetic is used to manage static web assets such as JavaScript, CSS, and image files. It was created in the pre-Webpack era, when JavaScript was sprinkled on a page to add interactivity rather than used to power your whole app, and at a time when frontend build tooling was limited. (In modern versions of Symfony, Assetic is deprecated in favor of a solution built on top of Webpack.) When using Assetic, JavaScript files are included on the page via the javascripts tag in a Twig template file.

{% javascripts
    "@quill_editor_js"
    "../../js/dist/homepage/app.js"
    "../../Control/assets/js/analytics/EventTracking.js"
    "../../Control/assets/js/vendor/ng/select/select.js"
    "../../Control/assets/js/vendor/ng/select/angular-sanitize.js"
    "../../Control/assets/js/vendor/jquery.readmore.js"
    "../../Control/assets/js/vendor/owl.carousel.min.js"
    "@CourseHeroBookBundle/Resources/assets/js/Video/youtube-player-factory.js"
    "@CourseHeroMarketingBundle/Resources/assets/js/HomepageAmplitudeTracking.js"
%}
    <script type="text/javascript" src="{{ asset_url }}"></script>
{% endjavascripts %}

In this example, we pass a handful of JavaScript files to the javascripts tag. Assetic takes these files, concatenates them into a single asset, and generates a hashed asset_url which is what ultimately appears in the src attribute of the script tag in the html that is sent to a user’s browser.

In a scenario where we are using Webpack to build a JavaScript app, however, the benefit of having Assetic concatenate our assets together is greatly reduced. Webpack already bundles our source files, so we don’t really need Assetic to do that anymore. As such, when we started building larger features of our site with React and using Webpack to bundle our code, the javascripts tag in the Twig file often looked more like

{% javascripts
    "../../js/dist/qa-landing/app.js"
%}
    <script type="text/javascript" src="{{ asset_url }}"></script>
{% endjavascripts %}

Here we are passing a single JavaScript file to the javascripts tag, since Webpack has already bundled our source files and npm packages into a single output file.

At this point, the only reason to use Assetic is so that the script tag’s src will be some hashed url, allowing us to tell the browser to cache the file without worrying about a user retrieving a stale version from cache since the url will change if we change the file contents. Webpack can also generate a hashed filename, but we would not want to have to pass this hashed filename to the javascripts tag in our Twig file because then we would have to update the Twig file with the new hashed filename every time we rebuild our app.

<!-- Obviously bad if we have to actually write this in source code! -->
<script type="text/javascript" src="../../js/dist/qa-landing/app.f3cb6eb09bb0fb1b6198.js"></script>

Webpack provides solutions to avoid having to do this, such as HtmlWebpackPlugin, but this plugin didn’t quite meet our needs.

So we continued to use Assetic to serve our JavaScript assets, even when the app was already bundled with Webpack. And for awhile, this worked perfectly well. As we started building larger and larger apps with React, though, we began to want to leverage code-splitting so that users do not have to download a bunch of unnecessary JavaScript on page load.

The React docs show how easy it is to code-split your app using React.lazy and the dynamic import() syntax. It seemed like we could just throw some of those into the appropriate places in our React app, and everything should work. Unfortunately, this was not the case for us. When Webpack code-splits your app, it needs to inject code that will load the various chunks when the dynamic import statements execute. As such, the bundle Webpack creates contains references to the relative paths and filenames of these chunks. However, since we were still passing our output file to Assetic, those references would be broken when Assetic processed our file and generated a new output file in some other directory with that hashed asset_url mentioned above. The contents of the file generated by Assetic still referenced the various chunks created by Webpack, but the relative path was no longer correct!

There were a few ways we could have gone about fixing this. One solution might be to try to fix the relative paths in the file generated by Assetic. This didn’t seem ideal since we’d likely have to do some parsing (whether string parsing or parsing the actual JavaScript code to find the references) and string manipulation to fix the relative paths. Yuck. A simpler solution might be to simply copy the chunks generated by Webpack into the same output directory that Assetic was writing its output file to. That way, the relative path should be the same as before. This is a reasonable approach and could probably work well. However, it created some difficulties for us because of the way our build process is optimized to avoid re-building apps which have not changed since the last build. Therefore, we opted for a third approach: to simply stop using Assetic for apps built with Webpack.

If we go back to the role Assetic was playing for us, I pointed out above that we no longer needed it to concatenate multiple source files since Webpack bundles our source code. The only benefit it was still providing was that we could write a non-hashed filename in our source code and Assetic would turn it into a hashed filename. If there was a way we could do this with Webpack, then we could eliminate the remaining benefit of Assetic. Conveniently, webpack-manifest-plugin provides just the information we need in order to do this. This plugin produces a JSON file that maps the unhashed filename to the actual, generated, hashed filename.

{
    "qa-landing/app.js": "/assets/js/compiled/qa-landing/app.c97e46a5876bd6275aeb.js",
}

Using this information, we can create a custom Twig extension that takes the unhashed filename as input and returns the output path to the hashed file generated by Webpack. This allows us to write "qa-landing/app.js" in our Twig file and have the extension automatically turn this into the actual filename.

With this approach, we have solved both of the problems that were preventing us from being able to code split our React apps.

In summary:

  1. The first problem was that Assetic broke the relative path references in our Webpack bundle when it processed our file and genereated a new output file in some other directory. We solved this by no longer using Assetic.
  2. The second problem was that we needed to serve a hashed filename so it could be cached by browsers and CDNs while still writing a constant, unhashed filename in our Twig file. We solved this by leveraging webpack-manifest-plugin and a custom Twig extension.

Further notes

  • We chose to write our Twig extension such that it would return raw HTML which should be injected into the Twig file. This means the Twig file now contains something like
{{ "qa-landing/app.js" | webpack_asset }}

and

{{ "qa-landing/app.css" | webpack_asset }}

webpack_asset is the name of our Twig extension, which translates the unhashed filename into the actual filename and returns a <script> tag for JavaScript files or a <link> tag for css files. (In order to have the Twig extension return HTML which should be injected directly into the Twig file without escaping, you can use the is_safe option.) The benefit of returning the HTML tags instead of simply returning the actual filename is that we can centralize more logic around how we serve the JavaScript bundle. For example, this Twig extension handles the logic for serving the bundle from localhost when developers are doing local development and running Webpack DevServer. It also handles the logic for our differential serving setup, where we serve a modern JavaScript bundle to modern browsers and a more transpiled bundle to older browsers.

  • In order to optimize the performance of the Twig extension, we used a Symfony compiler pass to read the Webpack manifest JSON files at compile time and keep the mapping from unhashed filename to generated hashed filename in memory. This way, at runtime, the generated hashed filename can be returned from memory rather than needing to read the Webpack manifest JSON file from disk on each page request.

GithubTwitterLinkedin