Learning

How we modernized the legacy Javascript in our Rails app

Dallas Read's profile picture Dallas Read on

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.

Stripe.js not loaded

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.

Jasmine to Jest

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.

Share on Twitter and Facebook

Dallas Read's profile picture

Dallas Read

Dream. Risk. Win. Repeat.

We think domain management should be easy.
That's why we continue building DNSimple.

Try us free for 30 days
4.5 stars

4.3 out of 5 stars.

Based on Trustpilot.com and G2.com reviews.