How we modernized the legacy Javascript in our Rails app
We're always working to improve the quality, reliability, and developer-happiness in DNSimple's customer-facing code. Throughout 2020, we've made some significant progress in both the Javascript and Ruby worlds.
Improving customer-facing code is usually an ambitious, detailed project, and we wanted to share more of the process with you. In this post, we'll look specifically at our Javascript undertaking to find what worked, what didn't, and what's next.
The process
In June 2020, we started tracking client-side Javascript errors using Bugsnag. Within a couple weeks, it became clear that we were unaware of many issues. In our July 2020 planning session, the team decided to allocate some time to "eliminate front end errors". After three months, we brought our total unique errors down from ~315 to ~0, modernized our Javascript stack, and moved our legacy tests to our jest
testing environment.
Finding resilient solutions
The DNSimple app is built on Rails, with half of our Javascript coming from Sprockets
via the asset pipeline and the remainder coming from Webpack
. Any solution we found had to work equally well in both contexts, even if it meant exposing global objects on window
for the time being.
Grouping errors to identify patterns
Early on, we grouped the errors into categories, ordered by customer impact. Our list looked something like this:
Customer Impact | Description |
---|---|
★★★★★ | Unsupported browser methods that probably require a polyfill |
★★★★★ | 3rd Party JS library is not loaded |
★★★★ | Basic errors: Undefined is not a … |
★★★★ | Unsupported browser methods that can be removed |
★★★ | Code quality |
★ | Browser-specific event errors |
★ | Errors to be silenced |
Group 1: Support for older browsers
Once the first wave of bugs was in, we found some of our customers are using older browsers that aren't compatible with some of the functionality of Javascript code.
One good resolution was to use polyfills. We could load their script from their CDN and let them handle the heavy lifting. Ultimately, we didn't settle on this, as it would be a network request outside of our control.
After some experimentation, we landed on the polyfill approach recommended by Webpacker:
import 'core-js/stable';
import 'regenerator-runtime/runtime';
Because we had a few items shared between our sprockets and Webpack builds (AJAX, jQuery, etc), we opted to add this polyfill code in a new shims
pack, which would be required in both Javascript contexts.
Group 2: AJAX wrapper
The next group of errors to tackle was related to failed or unexpected AJAX responses. Early on, @ggalmazor pointed out that the DNSimple app would benefit from a standardized pattern for interacting with AJAX requests. This would solve the current error group and eliminate future errors. It would also force us to standardize our server-side responses using status codes and uniform error management.
As a starting point, our new ajax
function was to use a similar API to jQuery's $.ajax
, but would return AjaxResponse
and AjaxError
objects with convenience methods. In addition, we could migrate our tests to use async
/ await
syntax. This would make our AJAX tests more succinct and readable.
Group 3: Adding a Linter
By this time, we had many hands in our Javascript code. Arguments ensued regarding spacing, syntax, null checks, and more. It became evident that we needed help from the 🤖.
We had been experiencing similar pains at dnsimple-node. @san983 suggested that we use eslint
with semi-standard
— the perfect solution. We added two scripts to our package.json
:
{
"scripts": {
"lint": "eslint app spec --ext .js,.vue",
"lint:fix": "eslint app spec --ext .js,.vue --fix"
}
}
To help facilitate the use of our new code formatter, we added yarn lint
to our Travis build scripts. I've added eslint
to my code editor to format .js
and .vue
files every time I save. I've also appended yarn lint:fix
to my pre-commit
git hook.
This was painless to adopt and quickly quieted discussions of spacing, syntax, and styling. Are four spaces still better than two? Absolutely. But now we can move on.
Group 4: Empty AJAX requests
We had many errors like: rails-ujs has already been loaded!
and undefined is not a function
. These stemmed from a AJAX requests to a blank URL. In some cases, users had been logged out and received our login page as the AJAX response.
When an empty URL is requested through AJAX, most browsers fetch the current page. Our code was taking this response (eg. the HTML for our login page), and replacing elements in the client's browser with $(el).html(server_response)
. Generally speaking, this isn't a great way to use the power of Javascript; it's part of our legacy system.
To resolve this, we added a before_action
filter to our Rails controllers for our AJAX actions. Our :ensure_xhr_request
is simple:
def ensure_xhr_request
request.xhr? or head(406)
end
Now, in our AJAX wrapper, we can handle this case with a single if
statement, which is usually coupled with our new method from the AJAX wrapper: ajaxError.isClientError()
.
Group 5: Third-Party CDN dependencies
Another group of issues stemmed from reliance on third-party CDNs. For instance, Stripe.js (our payment processor) wasn't loading our billing forms for some customers. At first, this seemed to be a failed network request. After weeks of monitoring, a pattern emerged. At one point, the same customer encountered the same error four times back-to-back. It was too coincidental for this to be the result of a failed network request.
After doing more digging, we discovered that the Adblock extension could very well be the culprit. Because this is an important issue, we settled on a more holistic solution. When Stripe wasn't loaded, we could replace the billing form with a nicely-worded warning to turn off Adblock or switch browsers.
As unbelievable as it sounds, DNSound also made a showing in this bug round-up. We had been loading Howler from a CDN, which failed in a small number of cases. To resolve this, we moved this dependency into yarn
. As an added bonus, we don't need to rely on a 3rd party anymore.
One way to run Javascript tests
Because some dependencies of our legacy test suite have been deprecated, we started to investigate how to unify and upgrade our Javascript test suite. Our legacy build relied on jasmine-rails
and was run with rspec spec:javascript
. Our "modern" Vue components were tested with jest
using the yarn test:unit
command.
The Later Fix
While exploring our testing frameworks, we found we could use a tool like js2.coffee to transpile our .coffeescript
files to es6
syntax. This would also allow us to move our legacy vendor
assets over to yarn
for security and maintenance purposes. Most importantly, this process would make our dependencies explicit – making for easier testing and re-usability.
When our first batch of es6
-transpiled code hit production, we immediately noticed this approach wasn't working as intended. Now that our Javascript was being spread across three scripts, jQuery
's initialization was wreaking havoc in our Vue
components. It was time for a code rollback.
We were in the closing weeks of the quarter. While we knew this approach had great potential, it couldn't be completed in the allocated time. On the advice of @weppos, we started focusing on one specific issue: unifying our Javascript testing environments.
The Now Fix
Not fully knowing where to begin, we added our legacy .coffee
files to our modern testing suite in jest.config.js
:
module.exports = {
testMatch: ['**/*.spec.js', '**/*_spec.coffee']
};
On the first run of yarn test:unit
, the compiler was throwing errors about syntax. After researching Jest
's configuration options, we came across transform
, which seemed to do what we needed:
module.exports = {
transform: {
'.coffee': '<rootDir>/jest-coffeescript-preprocessor.js'
}
};
Our jest-coffeescript-preprocessor.js
looking like this:
const coffee = require('coffeescript');
module.exports = {
process: function (src, filename) {
return coffee.compile(src, { bare: true });
}
};
On the next run of yarn test:unit
, the entirety of both test suites were being run 🔴! Now, it was a matter of fixing the errors one-by-one. I took the first .coffee
spec file and made it pass. Then another. And another.
After completing several spec files, patterns started to emerge about the way jasmine
functions mapped to jest
functions. I created a file called jasmine-to-jest.js
, included it in our test-setup.js
, and started monkey-patching.
As more and more specs were passing, jasmine-to-jest.js
started to fill up. Because this was fixing the problem at an elevated level of abstraction, each failing test became easier to fix. In the end, our file looked something like this:
const spyOn = (obj, methodName) => {
const fn = jest.fn();
fn.and = {
callFake: (valFn) => fn.mockImplementation(valFn),
returnValue: (val) => fn.mockReturnValue(val)
};
fn.calls = {
mostRecent: () => ({ args: fn.mock.calls[fn.mock.calls.length - 1] }),
any: () => !!fn.mock.calls.length,
argsFor: (index) => fn.mock.calls[index],
allArgs: () => JSON.stringify(fn.mock.calls.flat()),
first: () => ({ args: fn.mock.calls[0] })
};
obj[methodName] = fn;
return obj[methodName];
};
const readOnlySpyOn = (obj, parentMethodName, methodName) => {
delete obj[parentMethodName];
obj[parentMethodName] = {};
return spyOn(obj[parentMethodName], methodName);
};
const createSpyObj = (baseName, methodNames) => {
const obj = {};
for (let i = 0; i < methodNames.length; i++)
spyOn(obj, methodNames[i]);
return obj;
};
const any = (item) => expect.any(item);
export default {
any,
createSpyObj,
readOnlySpyOn,
spyOn
};
🚀 The final Pull Request to upgrade from jasmine
to jest
consisted of changing 62 files, with virtually all of them needing a single change to the import path.
What we learned
This was a great undertaking to explore our legacy Javascript, how it interacts with our Vue components, and a path to fully upgrade our .coffee
files. And the project as a whole enhanced our Javascript code in many ways:
- We became more aware of customer-facing issues.
- We improved the quality and stability of our code.
- We avoided past, present, and future code-styling arguments.
- We established patterns for common interactions.
- We were able to simplify our testing environment.
- We paved the way to fully unify our Javascript stack.
You can use our ever-powerful Javascript app to maintain your domains and DNS records at DNSimple.com.
Not using DNSimple yet? Give us a try free for 30-days to experience our robust API and reliable customer-facing code for yourself. If you have more questions, get in touch – we'd love to help.
Dallas Read
Dream. Risk. Win. Repeat.
We think domain management should be easy.
That's why we continue building DNSimple.
4.3 out of 5 stars.
Based on Trustpilot.com and G2.com reviews.