Skip to main content

Bits of Bad Code #1 - Unexpected Issues with Javascript Dates

· 6 min read
Software Engineer

This article describes a recent bug in Facebook's Docusaurus project. I was not the first to discover this bug, nor was I the one to ultimately fix it, but it makes for a very interesting deep dive into how to triage and resolve an issue.

I want to preface this by saying that this really wasn't bad code. It was buggy, but it was a mistake a lot of us could have made and my use of it as an example in this series is in no way a comment on the quality or competence of the engineers who originally wrote it. In fact, it was the clean and clear code structure of the Docusaurus project that made it possible to identify the issue relatively easily even for those of us not already familiar with the codebase.

I also want to acknowledge GitHub users @ysulyma for identifying the issue and @Josh-Cena for ultimately resolving the issue. And a kudos to the Docusaurus team for their quick and thorough process of accepting PRs that led to the issue being resolved in a timely fashion.

The Problem

Docusaurus is a static web application framework for writing documentation. It also has a blog feature. The issue identified an off-by-one error in the rendered dates appearing on blog posts in Docusaurus.

This website uses Docusaurus and I noticed the error on when an article I posted on June 15th had a final rendered date of June 14th.

How It Works

Docusaurus determines the date of a blog post based on the filename of the post. Posts are written in Markdown and the files are named with the following convention: YYYY-MM-DD-some-title.md.

Docusaurus looks at the date at the beginning of the file name to determine the date of the post. A simplified version of how this worked looks something like this:

const DATE_FILENAME_PATTERN = /^(\d{4}-\d{1,2}-\d{1,2})-?(.*?).mdx?$/;
const dateFilenameMatch = blogFileName.match(DATE_FILENAME_PATTERN);
const [, dateString, name] = dateFilenameMatch;
let date = new Date(dateString);
const formattedDate = new Intl.DateTimeFormat('en', {
day: 'numeric',
month: 'long',
year: 'numeric',
}).format(date);

The original file (before the fix) can be found here.

The Bug

The problem arises from how Javascript handles dates. But initial attempts to track down issue were masked by Javascript's Date object behavior. Consider this first attempt to debug the issue:

const DATE_FILENAME_PATTERN = /^(\d{4}-\d{1,2}-\d{1,2})-?(.*?).mdx?$/;
const dateFilenameMatch = blogFileName.match(DATE_FILENAME_PATTERN);
const [, dateString, name] = dateFilenameMatch;
let date = new Date(dateString);
console.log(`Debug: ${date}`);
const formattedDate = new Intl.DateTimeFormat('en', {
day: 'numeric',
month: 'long',
year: 'numeric',
}).format(date);

I added a console.log() statement to print the date and I got some output that seemed to indicate the problem was with the date constructor.

Debug: Mon Jun 14 2021 20:00:00 GMT-0400 (Eastern Daylight Time)

At some point later in the process when things kept working strangely, I changed my print statement to simply console.log(date) which gave the output:

2021-06-15T00:00:00.000Z

What's going on here? After diving into a bunch of documentation, it turns out that three things are happening.

  1. Per the ECMA spec, when only date is given to the constructor, the hour, minutes, seconds, and milliseconds are zeroed out.
  2. When a timezone is not specified, the timezone is interpreted as UTC.
  3. When Javascript retrieves the Date object and converts it into a string, it converts it to local time. In the example above, console.log(`${date}`) is doing this implicitly similar to calling date.toString().

The Actual Bug

Much like the issue above, Intl.DateTimeFormat also converts the timestamp to local time implicitly. The problem stems from the timestamp being created from date only and being interpreted in UTC but then being retrieved and rendered in local time.

In my case, I'm in US eastern time. Here's what happens:

let dateString = '2021-06-15'; 
let date = new Date(dateString);
console.log(date); // 2021-06-15T00:00:00.000Z
console.log(date.toString()); // Mon Jun 14 2021 20:00:00 GMT-0400 (Eastern Daylight Time)

let fmtDate = new Intl.DateTimeFormat('en', {
day: 'numeric',
month: 'long',
year: 'numeric',
}).format(date);
console.log(fmtDate); // June 14, 2021

When the date is set to midnight UTC and then converted back into eastern time (minus 4 hours), the local time is June 14th, 20:00:00. Just the date portion of that is taken and used as the rendered blog date.

The Solution

Ultimately, this fix was simple. We needed to ensure that the formatted date was interpreted as the same time zone as the Date object to ensure that the printed date is the date the author intended.

The final fix did two things: it explicitly set the created date to UTC and it specified the the formatted date should remain in UTC. It looked something like this:

const DATE_FILENAME_PATTERN = /^(\d{4}-\d{1,2}-\d{1,2})-?(.*?).mdx?$/;
const dateFilenameMatch = blogFileName.match(DATE_FILENAME_PATTERN);
const [, dateString, name] = dateFilenameMatch;
let date = new Date(`${dateString}Z`);
const formattedDate = new Intl.DateTimeFormat('en', {
day: 'numeric',
month: 'long',
year: 'numeric',
timeZone: 'UTC'
}).format(date);

The final actual fix by can be found here.

Closing Thoughts

I thought this was an interesting problem. It shows the challenges of testing software thoroughly.

While this issue wasn't related to code coverage, it illustrates rather well why code coverage alone doesn't indicate that something is well tested. This code could have been hit by other tests (thus the code is covered), but the rendered output may never be checked.

More importantly, the possible inputs to this vary by timezone. Not only would effective testing need to control the timezone of the CI environment, but it would also need to test in multiple timezones.

As a thought experiment, consider the scenario of using a different date format. When the date string is 2021-6-15 (i.e. the month is one digit), there is no guarantee that the time is zeroed. Per the ECMA spec, the result is "implementation specific". Here are the results on my machine:

let dateString = '2021-6-15';

process.env.TZ = 'US/Pacific'
new Date(dateString); // 2021-06-15T07:00:00.000Z

process.env.TZ = 'US/Eastern';
new Date(dateString); // 2021-06-15T04:00:00.000Z

process.env.TZ = 'Asia/Shanghai';
new Date(dateString); // 2021-06-14T16:00:00.000Z

Ultimately, the quality of the Docusaurus codebase and the responsiveness of the maintainers led to a timely fix. The project's contribution guide is an excellent example of how to make testing an expected part of the engineering process.