Skip to content

Commit

Permalink
Merge pull request #9584 from bbc/modern-legacy-bundles
Browse files Browse the repository at this point in the history
Differential serving using module/nomodule scripts
  • Loading branch information
Jonathan Roebuck committed Dec 6, 2021
2 parents 19aa402 + c82c2a3 commit 26d2291
Show file tree
Hide file tree
Showing 24 changed files with 680 additions and 446 deletions.
61 changes: 38 additions & 23 deletions .babelrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,25 +22,41 @@ if (process.env.NODE_ENV === 'production') {
]);
}

module.exports = {
presets: [
const overrides = [
{
test: /.*logger\..*/,
sourceType: 'script',
},
];

module.exports = api => {
const env = api.env();
const useModern = env === 'modern';

const presets = [
[
'@babel/preset-env',
{
targets: {
browsers: [
'chrome >= 53',
'firefox >= 45.0',
'ie >= 11',
'edge >= 37',
'safari >= 9',
'opera >= 40',
'op_mini >= 18',
'Android >= 7',
'and_chr >= 53',
'and_ff >= 49',
'ios_saf >= 10',
],
...(useModern
? {
esmodules: true,
}
: {
browsers: [
'chrome >= 53',
'firefox >= 45.0',
'ie >= 11',
'edge >= 37',
'safari >= 9',
'opera >= 40',
'op_mini >= 18',
'Android >= 7',
'and_chr >= 53',
'and_ff >= 49',
'ios_saf >= 10',
],
}),
node: 'current',
},
// analyses code & polyfills only the features that are used, only for the targeted browsers
Expand All @@ -49,12 +65,11 @@ module.exports = {
},
],
'@babel/preset-react', // transform JSX to JS
],
plugins: plugins,
overrides: [
{
test: /.*logger\..*/,
sourceType: 'script',
},
],
];

return {
presets,
plugins,
overrides,
};
};
42 changes: 40 additions & 2 deletions docs/JavaScript-Bundling-Strategy.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,46 @@
# JavaScript Bundling

Because we make multiple releases per day with updated application and library (node_module) code we split our client-side JavaScript bundle into multiple chunks to improve cache efficiency so that the amount of cache-invalidated chunks after each deployment is kept to a minimum.
## Differential serving - modern and legacy bundles using module/nomodule scripts

Simorgh creates 2 client-side JavaScript bundles. The 2 bundles are made of multiple scripts (or chunks) that are prefixed with `legacy.` and `modern.`, for example, `legacy.main-49d0a293.a47dd2b9.js` and `modern.main-49d0a293.abb18c4e.js`.

### Why create modern and legacy bundles?

Legacy browsers need the JavaScript we write to be transformed into something the browser is able to understand and often needs polyfills packaged along with it for missing features of the language. This bloats the JavaScript bundle size and reduces performance for modern browsers that do not need as many transformations or polyfills. At the time of writing, roughly 95% of browsers in use have support for ES2017 syntax and understand most of the code we write without using as many transformations or polyfills. By building 2 separate bundles and conditionally loading and executing only 1 we are increasing the performance for roughly 95% of our users while still providing support to legacy browsers.

### How is this achieved in Simorgh?

Simorgh will conditionally load and execute all scripts prefixed with either `legacy.` or `modern.` depending on your browser. Currently, Simorgh considers a browser to be modern if it supports ES2017 syntax and transpiles legacy JavaScript to ES5 syntax for browsers such as IE11 and Opera Mini.

Simorgh uses Webpack to build 2 different client-side bundles. Most of the client-side Webpack configuration is found in `webpack.client.js`. This config is run with a `BUNDLE_TYPE` argument that returns config for a `modern` or `legacy` browser. The Webpack config uses config from `.babelrc.js` to provide the appropriate JavaScript transformations and polyfills. `.babelrc.js` also needs to dynamically return modern or legacy config but does so using the `process.ENV` variable that is conditionally set using `envName` in the `babel-loader` options in the Webpack config.

Now that we have the mechanism for generating 2 separate bundles we need to include them in the HTML document. Both modern and legacy bundles need added to the document but the conditional loading and executing is handled using the [module/nomodule](https://3perf.com/blog/polyfills/#modulenomodule) pattern. For example:

```html
<!-- legacy browsers ignore scripts with type="module" -->
<script type="module" src="modern.main-49d0a293.abb18c4e.js"></script>

### Our strategy
<!-- modern browsers ignore scripts with nomodule -->
<script nomodule src="legacy.main-49d0a293.a47dd2b9.js"></script>
```

Simorgh uses Loadable Components (a library Simorgh uses for code-splitting) to handle generating script elements. On the server-side, Loadable Components analyses 2 stats files (modern and legacy) generated by Webpack so it can generate the script elements needed in the HTML document. On the client-side, the Loadable Components library queries the DOM for a json script tag (by tag ID `legacy__LOADABLE_REQUIRED_CHUNKS__` or `modern__LOADABLE_REQUIRED_CHUNKS__` which is set using the `namespace` option on the server-side) that contains the JavaScript chunk IDs that Loadable Components will asynchronously load.

### Gotchas

- Safari 10.1 supports modules, but does not support the `nomodule` attribute. This results in Safari downloading and executing both legacy and modern bundles. A polyfill has been included to prevent this behaviour.
- IE11 downloads both modern and legacy bundles but only executes the legacy bundle. This worsens performance for IE11 users because they will have to download almost twice the amount of JavaScript. IE11 accounts for around 0.06% of page visits across the World Service sites. Based on this we decided the impact is not high enough to prevent us from providing a better experience to the vast majority of users.
- The Webpack dev server run using `yarn dev` currently only uses modern JavaScript. If you are cross-browser testing locally make sure that you build Simorgh with `yarn build` and start the Express server with `yarn start`.
- The bundle analyser script that runs after builds and displays bundle size information by default will run on the modern bundle. If you would like to see bundle size information for the legacy bundle you can run a build (`yarn build`) and then run `bundleType=legacy node scripts/bundleSize`.

### More on legacy vs modern bundles

- [Publish, ship, and install modern JavaScript for faster applications](https://web.dev/publish-modern-javascript/)
- [Deploying ES2015+ Code in Production Today](https://philipwalton.com/articles/deploying-es2015-code-in-production-today/)

## Code-splitting

Because we make multiple releases per day with updated application and library (node_module) code we split our client-side JavaScript bundle into multiple chunks to improve cache efficiency so that the amount of cache-invalidated chunks after each deployment is kept to a minimum.

Currently, our chunking strategy is as follows:

Expand Down
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"scripts": {
"amp:validate": "wait-on -t 20000 http://localhost:7080/status && node ./scripts/ampHtmlValidator/cli.js",
"build:local": "rm -rf build && cp envConfig/local.env .env && NODE_ENV=production webpack",
"build": "yarn build:local && node ./scripts/bundleSize/index.js",
"build": "yarn build:local && node ./scripts/bundleSize",
"build:profile": "rm -rf build && cp envConfig/local.env .env && IS_PROD_PROFILE=true NODE_ENV=production webpack",
"build:live": "cp envConfig/live.env .env && NODE_ENV=production webpack",
"build:live:debug": "rm -rf build && awk '{sub(/LOG_DIR=.+/,\"LOG_DIR='log'\")}1' envConfig/live.env > .env && NODE_ENV=production webpack",
Expand Down Expand Up @@ -58,8 +58,8 @@
"test:linkey": "node scripts/linkeySetup.js && jest src/app/lib/config/services/*.test.js --verbose true; yarn test:linkey:cleanup",
"test:linkey:cleanup": "find src/app/lib/config/services -type f -name '*.test.js' -delete",
"updateMinorPatch": "rm -rf node_modules/ && yarn install && npm update && yarn install",
"webpack:dev:client": "NODE_ENV=development webpack serve --hot --env config='client'",
"webpack:dev:server": "wait-on ./build/public/loadable-stats-local.json && NODE_ENV=development webpack --watch --env config='server'"
"webpack:dev:client": "NODE_ENV=development webpack serve --config-name='modern' --hot --env config='client'",
"webpack:dev:server": "wait-on ./build/public/modern-loadable-stats-local.json && NODE_ENV=development webpack --watch --env config='server'"
},
"repository": {
"type": "git",
Expand Down
9 changes: 6 additions & 3 deletions puppeteer/bundleRequests.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,18 +79,21 @@ describe('Js bundle requests', () => {
.forEach(url => {
expect(url).toMatch(
new RegExp(
`(\\/static\\/js\\/(?:comscore\\/)?(main|framework|commons|shared|${serviceRegex}|frosted_promo|.+Page).+?.js)|(\\/static\\/.+?-lib.+?.js)`,
`(\\/static\\/js\\/(?:comscore\\/)?(modern.)?(main|framework|commons|shared|${serviceRegex}|frosted_promo|.+Page).+?.js)|(\\/static\\/.+?-lib.+?.js)`,
'g',
),
);
});
});

it('loads at least 1 service bundle', () => {
it('loads at least 1 modern service bundle', () => {
const serviceRegex = getServiceBundleRegex(config[service].name);
const serviceMatches = requests.filter(url =>
url.match(
new RegExp(`(\\/static\\/js\\/${serviceRegex}.+?.js)`, 'g'),
new RegExp(
`(\\/static\\/js\\/modern.${serviceRegex}.+?.js)`,
'g',
),
),
);

Expand Down
Loading

0 comments on commit 26d2291

Please sign in to comment.