a person holing a sticker in close up photography

Debugging and Patching NodeJS Dependencies

While working on a Shopify project, we employed Prettier for formatting our JavaScript, HTML, and CSS files. Shopify however, uses Liquid for templates which Prettier does not support out of the box but fortunately, Shopify developed a Liquid Prettier plugin to handle Liquid files.

When using it, we noticed that the formatting of Prettier on certain Liquid files is not idempotent (formatting it twice should have the same result as formatting it once): in some cases, formatting a Liquid file that has a script tag will result in new lines being appended after the opening script tag and before the closing script tag per run. So when you have something like the following in a .liquid file:

Liquid
<script>
  {% if a %}
  console.log(`{{ a }}`);
  {% endif %}
</script>
Liquid

Prettier will format it to

Liquid
<script>

  {% if a %}
  console.log(`{{ a }}`);
  {% endif %}

</script>
Liquid

And running Prettier again will make it add more lines. It’s a small but annoying bug that prevents us from automating Prettier.

This bug has been identified and filed in their GitHub repository but since no one has picked it up since March, I want to try to take a stab and fix it.

Generating the minimal test case is to first generate a minimal test case. After experimenting a bit and reducing the sample case in the issue, I came up with the following:

Liquid
<script>
  ({{ a }});
  ``;
</script>
Liquid

And a counter-example (where the bug does not trigger), notice the use of double quotes instead of backticks:

Liquid
<script>
  ({{ a }});
  "";
</script>
Liquid

Fortunately, the prettier-plugin-liquid project has a test suite so it’s just a matter of adding our test case to that and adding a new npm script to just run that specific test case.

Identification

Briefly scanning through the code, we see that the process is split into two parts: parsing and printing. Furthermore, parsing is split into CST (“Concrete Syntax Tree”) generation then converted to an AST. So the question is where the bug is located: in the CST generation? The conversion to AST? or in printing?

A method I used to narrow down the source of the bug is to print the output of each stage, and compare the difference between the test case, which triggers the bug, and the counter-example, which does not trigger the bug. A good place to poke is the cstToAst function which has access to both CST and AST objects. We can add a logging statement to output both the CST and AST.

Seeing that the CST and AST output of both cases are actually the same (differing only our input), we can conclude that the issue is in the printing.

A good place to look for is the aptly named printRawElement. And checking the output of generating the body variable we find the difference:

However, following the code statically will be difficult due to the dynamic callback of path.call(print, 'body') so we will have to utilize a debugger to help us. Enter the NodeJS Debugger.

NodeJS Debugger

While console.log and console.trace gets us far, being able to stop execution and step through what each line of code does at runtime. Fortunately, NodeJS lets you debug programs using Chrome, VS Code, and various other clients.

First is to add a programmatic breakpoint in our code by adding the debugger; statement.

JavaScript
  if (!hasEmptyBody) {
    if (shouldIndentBody) {
      debugger;
      body = [indent([hardline, path.call(print, 'body')]), hardline];
    } else {
      body = [dedentToRoot([hardline, path.call(print, 'body')]), hardline];
    }
  }
JavaScript

Then, we modify package.json to add the --inspect-brk flag that tells NodeJS to start the debugger and pause execution (just passing --inspect will result in the program running immediately and possibly exit before one will have a chance to connect to it).

JavaScript
"test:target": "mocha --inspect-brk '{src,test}/issue-171/*.spec.ts'",
JavaScript

For debugging, I used Chrome and went to chrome://inspect to access the debugger client, waited a bit in the Devices tab for the target to show up

Once connected, we can use the familiar Chrome debugger to step through the code, since we added the programmatic breakpoint, continuing execution will result in the program pausing at that line:

Chrome DevTools paused at the line where the debugger statement is.

This allows us to step through the code and identify the source of the bug. After tracing through the execution, we can zero-in to this line that just returns the script tag contents verbatim, including the leading and trailing newlines https://github.com/Shopify/prettier-plugin-liquid/blob/main/src/printer/printer-liquid-html.ts#L276 so our solution is to simply return the trimmed string.

Patching Things Up

And to distribute the fix to the rest of the team without having to wait for them to merge the pull request, we can use patch-package to generate and apply the patch with the repository. Doing this is as simple as editing the file directly in node_modules/@shopify/prettier-plugin-liquid/dist/printer/printer-liquid-html.js then running npx patch-package '@shopify/prettier-plugin-liquid' to generate the following patch file under the patches folder, which should be checked-in with the project:

Plaintext
diff --git a/node_modules/@shopify/prettier-plugin-liquid/dist/printer/printer-liquid-html.js b/node_modules/@shopify/prettier-plugin-liquid/dist/printer/printer-liquid-html.js
index 421b76c..e4e68dd 100644
--- a/node_modules/@shopify/prettier-plugin-liquid/dist/printer/printer-liquid-html.js
+++ b/node_modules/@shopify/prettier-plugin-liquid/dist/printer/printer-liquid-html.js
@@ -136,7 +136,7 @@ function printNode(path, options, print, args = {}) {
                 }
             };
             if (isRawMarkupIdentationSensitive()) {
-                return node.value;
+                return node.value.trim();
             }
             const lines = (0, utils_2.bodyLines)(node.value);
             const rawFirstLineIsntIndented = !!((_a = node.value
Plaintext

To automate the application of the patch when installing the dependencies, add the following entry in the scripts of your project’s package.json file

JSON
"postinstall": "patch-package"
JSON

Conclusion

We did an end-to-end walkthrough of using debugging tools to identify and fix bugs in a 3rd-party dependency and then patching the fix for internal teams without needing for upstream maintainers to merge the fix.

Aside from debugging and fixing code, we at Bonito Tech do software development and consultancy. Need help with implementing your project or supplement it with additional expertise? Contact us!